diff --git a/.claude/commands/icon.md b/.claude/commands/icon.md index 7e7bde46c3..479e23eceb 100644 --- a/.claude/commands/icon.md +++ b/.claude/commands/icon.md @@ -127,7 +127,7 @@ Locate the render tool (`codegen_style` with `--render-svg` mode): ```bash if [[ "$OSTYPE" == darwin* ]]; then - ls out/Debug/codegen_style + ls out/Telegram/codegen/codegen/style/Debug/codegen_style else ls out/Telegram/codegen/codegen/style/Debug/codegen_style.exe fi @@ -137,7 +137,7 @@ If missing, build it: `cmake --build out --config Debug --target codegen_style` Test on a known good SVG (use the appropriate binary path for the OS): ```bash -CODEGEN=$(if [[ "$OSTYPE" == darwin* ]]; then echo out/Debug/codegen_style; else echo out/Telegram/codegen/codegen/style/Debug/codegen_style.exe; fi) +CODEGEN=$(if [[ "$OSTYPE" == darwin* ]]; then echo out/Telegram/codegen/codegen/style/Debug/codegen_style; else echo out/Telegram/codegen/codegen/style/Debug/codegen_style.exe; fi) $CODEGEN --render-svg Telegram/Resources/icons/menu/tag_add.svg .ai/icon_{name}/test_render.png 512 ``` diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 150d3e917e..baaa7c261d 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -27,9 +27,11 @@ Read `changelog.txt` from the repository root. Note the **latest version number* ### 3. Determine the new version number -- **If a version was provided in arguments**, use it directly (append `.0` if only major.minor was given). +**Important:** version numbers are shared across the beta and stable tracks — the sequence advances through both. The same `major.minor.patch` cannot be released as both beta and stable. A beta release "uses up" that number; the next stable must bump to a new patch. + +- **If a version was provided in arguments**, use it directly (append `.0` if only major.minor was given). If the latest changelog entry already used this exact number (regardless of beta/stable), warn the user — they likely want a bumped patch. - **If no version was provided**, auto-increment from the latest changelog version: - - If it was a beta, and the new release is **not** beta, reuse the same version number but drop "beta". + - If it was a beta, and the new release is **not** beta, bump the patch component by 1 (do **not** reuse the beta's number for stable). - If the new release is beta and the latest was also beta with the same major.minor, bump patch. - Otherwise bump the patch component by 1. - Present the chosen version to the user and ask for confirmation before proceeding. If the user suggests a different version, use that instead. @@ -65,6 +67,10 @@ Use this exact format (date is today in DD.MM.YY): Prepend the new entry at the very top of `changelog.txt`, separated by a blank line from the previous first entry. Use the Edit tool. +**Never delete or edit existing entries**, even if the new stable entry merges bullets from prior beta(s). Prior beta blocks remain as historical record for beta-track users. + +**For stable releases spanning prior beta(s):** the entry should cover everything since the last **stable** release (not just since the last beta), including noteworthy beta-shipped features, trimmed as needed to stay within 4–12 bullets. + ### 8. Wait for approval After writing the entry to `changelog.txt` (step 7), tell the user the changelog has been updated and ask them to review it in the IDE. They can edit it directly and tell you to continue, or tell you what to change in chat. Do **not** print the full entry in chat — the file itself is the review surface. diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index a94c13542b..294f1ad3ef 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -44,4 +44,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deploy - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/unused_styles_updater.yml b/.github/workflows/unused_styles_updater.yml new file mode 100644 index 0000000000..2c021d1cd0 --- /dev/null +++ b/.github/workflows/unused_styles_updater.yml @@ -0,0 +1,16 @@ +name: Unused styles updater. + +on: + repository_dispatch: + types: ["Restart unused_styles_updater workflow."] + schedule: + - cron: "0 3 1 * *" + workflow_dispatch: + +jobs: + Unused-styles: + runs-on: ubuntu-latest + steps: + - uses: desktop-app/action_code_updater@master + with: + type: "unused-styles" diff --git a/AGENTS.md b/AGENTS.md index 9fffd0a059..ec1650302b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,6 +212,7 @@ api().request(MTPnamespace_MethodName( ``` - For single constructors, use `.data()` shortcut - Include `.handleFloodErrors()` before `.send()` in rare cases where you want special case flood error handling +- Silently ignore HTTP 406 errors in UI: the server uses 406 to mean "show nothing to the user". Guard toasts with `MTP::IgnoreError(error)` or use `MTP::ShowErrorFallback(show, error)` (both in `mtproto/mtproto_response.h`) which shows `error.type()` as a toast unless the error should be ignored. ## UI Styling diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 66847c81b5..9b459eab68 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -350,6 +350,8 @@ PRIVATE api/api_statistics_data_deserialize.h api/api_statistics_sender.cpp api/api_statistics_sender.h + api/api_stickers_creator.cpp + api/api_stickers_creator.h api/api_suggest_post.cpp api/api_suggest_post.h api/api_text_entities.cpp @@ -515,6 +517,8 @@ PRIVATE boxes/send_gif_with_caption_box.h boxes/send_files_box.cpp boxes/send_files_box.h + boxes/send_files_box_reply_header.cpp + boxes/send_files_box_reply_header.h boxes/share_box.cpp boxes/share_box.h boxes/star_gift_auction_box.cpp @@ -531,6 +535,8 @@ PRIVATE boxes/star_gift_preview_box.h boxes/star_gift_resale_box.cpp boxes/star_gift_resale_box.h + boxes/sticker_creator_box.cpp + boxes/sticker_creator_box.h boxes/sticker_set_box.cpp boxes/sticker_set_box.h boxes/stickers_box.cpp @@ -626,6 +632,8 @@ PRIVATE chat_helpers/emoji_keywords.h chat_helpers/emoji_list_widget.cpp chat_helpers/emoji_list_widget.h + chat_helpers/emoji_picker_overlay.cpp + chat_helpers/emoji_picker_overlay.h chat_helpers/emoji_sets_manager.cpp chat_helpers/emoji_sets_manager.h chat_helpers/emoji_suggestions_widget.cpp @@ -662,6 +670,8 @@ PRIVATE chat_helpers/ttl_media_layer_widget.h core/application.cpp core/application.h + core/proxy_rotation_manager.cpp + core/proxy_rotation_manager.h core/cached_webview_availability.h core/bank_card_click_handler.cpp core/bank_card_click_handler.h @@ -1593,6 +1603,8 @@ PRIVATE mtproto/facade.h mtproto/mtp_instance.cpp mtproto/mtp_instance.h + mtproto/proxy_check.cpp + mtproto/proxy_check.h mtproto/sender.h mtproto/session.cpp mtproto/session.h @@ -1656,6 +1668,8 @@ PRIVATE platform/linux/webauthn_linux.cpp platform/mac/file_utilities_mac.mm platform/mac/file_utilities_mac.h + platform/mac/global_menu_mac.h + platform/mac/global_menu_mac.mm platform/mac/launcher_mac.mm platform/mac/launcher_mac.h platform/mac/integration_mac.mm @@ -1813,6 +1827,8 @@ PRIVATE settings/sections/settings_local_passcode.h settings/sections/settings_main.cpp settings/sections/settings_main.h + settings/settings_recent_searches.cpp + settings/settings_recent_searches.h settings/settings_search.cpp settings/settings_search.h settings/settings_faq_suggestions.cpp @@ -2004,6 +2020,7 @@ PRIVATE window/window_section_common.h window/window_separate_id.cpp window/window_separate_id.h + window/session/window_session_media.cpp window/window_session_controller.cpp window/window_session_controller.h window/window_session_controller_link_info.h diff --git a/Telegram/Resources/icons/chat/text_to_file.svg b/Telegram/Resources/icons/chat/text_to_file.svg new file mode 100644 index 0000000000..d3d7ce7039 --- /dev/null +++ b/Telegram/Resources/icons/chat/text_to_file.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/dialogs/dialogs_unread_media_dot.svg b/Telegram/Resources/icons/dialogs/dialogs_unread_media_dot.svg new file mode 100644 index 0000000000..d0da6ff5fc --- /dev/null +++ b/Telegram/Resources/icons/dialogs/dialogs_unread_media_dot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Telegram/Resources/icons/menu/sticker_add.png b/Telegram/Resources/icons/menu/sticker_add.png new file mode 100644 index 0000000000..0a4ee1dbc7 Binary files /dev/null and b/Telegram/Resources/icons/menu/sticker_add.png differ diff --git a/Telegram/Resources/icons/menu/sticker_add@2x.png b/Telegram/Resources/icons/menu/sticker_add@2x.png new file mode 100644 index 0000000000..72c830ca74 Binary files /dev/null and b/Telegram/Resources/icons/menu/sticker_add@2x.png differ diff --git a/Telegram/Resources/icons/menu/sticker_add@3x.png b/Telegram/Resources/icons/menu/sticker_add@3x.png new file mode 100644 index 0000000000..2bfe690b77 Binary files /dev/null and b/Telegram/Resources/icons/menu/sticker_add@3x.png differ diff --git a/Telegram/Resources/icons/menu/sticker_select.png b/Telegram/Resources/icons/menu/sticker_select.png new file mode 100644 index 0000000000..788a9a317c Binary files /dev/null and b/Telegram/Resources/icons/menu/sticker_select.png differ diff --git a/Telegram/Resources/icons/menu/sticker_select@2x.png b/Telegram/Resources/icons/menu/sticker_select@2x.png new file mode 100644 index 0000000000..19cec30c9b Binary files /dev/null and b/Telegram/Resources/icons/menu/sticker_select@2x.png differ diff --git a/Telegram/Resources/icons/menu/sticker_select@3x.png b/Telegram/Resources/icons/menu/sticker_select@3x.png new file mode 100644 index 0000000000..04238df988 Binary files /dev/null and b/Telegram/Resources/icons/menu/sticker_select@3x.png differ diff --git a/Telegram/Resources/icons/menu/text_style_framed.svg b/Telegram/Resources/icons/menu/text_style_framed.svg new file mode 100644 index 0000000000..de5e3ff728 --- /dev/null +++ b/Telegram/Resources/icons/menu/text_style_framed.svg @@ -0,0 +1,9 @@ + + + Icon / Menu / text_framed + + + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/menu/text_style_plain.svg b/Telegram/Resources/icons/menu/text_style_plain.svg new file mode 100644 index 0000000000..72acd48596 --- /dev/null +++ b/Telegram/Resources/icons/menu/text_style_plain.svg @@ -0,0 +1,7 @@ + + + Icon / Menu / text_plain + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/menu/text_style_semi.svg b/Telegram/Resources/icons/menu/text_style_semi.svg new file mode 100644 index 0000000000..95cca3188f --- /dev/null +++ b/Telegram/Resources/icons/menu/text_style_semi.svg @@ -0,0 +1,8 @@ + + + Icon / Menu / text_semi + + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/photo_editor/corners.svg b/Telegram/Resources/icons/photo_editor/corners.svg new file mode 100644 index 0000000000..09b0428433 --- /dev/null +++ b/Telegram/Resources/icons/photo_editor/corners.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 7d9b0b0e7f..831b3cb3ad 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -339,6 +339,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_proxy_invalid" = "The proxy link is invalid."; "lng_proxy_unsupported" = "Your Telegram Desktop version doesn't support this proxy type or the proxy link is invalid. Please update Telegram Desktop to the latest version."; "lng_proxy_incorrect_secret" = "This proxy link uses invalid **secret** parameter. Please contact the proxy provider and ask him to update MTProxy source code and configure it with a correct **secret** value. Then let him provide a new link."; +"lng_proxy_check_ip_warning_title" = "Warning"; +"lng_proxy_check_ip_warning" = "This will expose your IP address to the admin of the proxy server."; +"lng_proxy_check_ip_proceed" = "Proceed"; "lng_edit_deleted" = "This message was deleted"; "lng_edit_limit_reached#one" = "You've reached the message text limit. Please make the text shorter by {count} character."; @@ -1236,6 +1239,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_proxy_use_system_settings" = "Use system proxy settings"; "lng_proxy_use_custom" = "Use custom proxy"; "lng_proxy_use_for_calls" = "Use proxy for calls"; +"lng_proxy_auto_switch" = "Auto-switch proxies"; +"lng_proxy_auto_switch_about" = "You can choose how quickly the app should auto-connect to the nearest active proxy if the current one stops working."; +"lng_proxy_auto_switch_timeout#one" = "{count} s"; +"lng_proxy_auto_switch_timeout#other" = "{count} s"; "lng_proxy_about" = "Proxy servers may be helpful in accessing Telegram if there is no connection in a specific region."; "lng_proxy_add" = "Add proxy"; "lng_proxy_share" = "Share"; @@ -1388,6 +1395,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_blocked_list_confirm_title" = "Block {name}"; "lng_blocked_list_confirm_text" = "Do you want to block {name} from messaging and calling you on Telegram?"; "lng_blocked_list_confirm_clear" = "Delete this chat"; +"lng_blocked_list_confirm_reply" = "Delete this reply"; +"lng_blocked_list_confirm_reply_all" = "Delete all replies from {user}"; "lng_blocked_list_confirm_ok" = "Block"; "lng_blocked_list_empty_title" = "No blocked users"; "lng_blocked_list_empty_description" = "You haven't blocked anyone yet."; @@ -4496,16 +4505,51 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_stickers_context_edit_name" = "Edit name"; "lng_stickers_context_delete" = "Delete sticker"; "lng_stickers_context_delete_sure" = "Are you sure you want to delete the sticker from your sticker set?"; +"lng_emoji_context_delete" = "Delete emoji"; +"lng_emoji_context_delete_sure" = "Are you sure you want to delete this emoji from your set?"; +"lng_stickers_context_delete_pack" = "Delete Pack"; +"lng_stickers_context_delete_pack_everyone" = "Delete for Everyone"; +"lng_stickers_context_delete_pack_self" = "Delete for Myself"; +"lng_stickers_delete_pack_sure" = "Are you sure you want to delete this sticker set for everyone? This cannot be undone."; +"lng_stickers_bot_more_options" = "Check the {bot} bot for more options"; "lng_stickers_box_edit_name_title" = "Edit Sticker Set Name"; "lng_stickers_box_edit_name_about" = "Choose a name for your set."; "lng_stickers_creator_badge" = "edit"; +"lng_stickers_create_new" = "Create a New Sticker"; +"lng_stickers_add_existing" = "Add an Existing Sticker"; +"lng_stickers_add_to_set" = "Add to Sticker Set"; +"lng_stickers_already_in_set" = "This Sticker is already in the Set."; +"lng_stickers_set_is_full" = "This Sticker Set is full."; +"lng_emoji_add_to_set" = "Add to Emoji Set"; +"lng_emoji_already_in_set" = "This Emoji is already in the Set."; +"lng_emoji_set_is_full" = "This Emoji Set is full."; +"lng_emoji_added" = "Emoji added."; +"lng_stickers_pack_choose_emoji_title" = "Choose Emoji"; +"lng_stickers_pack_choose_emoji_about" = "Pick an emoji that corresponds to this sticker."; +"lng_stickers_pick_existing_title" = "Choose Sticker"; +"lng_stickers_pick_existing_about" = "Pick a sticker from your library to add it to this set."; +"lng_stickers_pick_existing_empty" = "You don't have any stickers yet."; +"lng_stickers_create_image_title" = "New Sticker"; +"lng_stickers_create_image_about" = "Choose an image to add as a sticker."; +"lng_stickers_create_choose_image" = "Choose Image"; +"lng_stickers_create_image_filter" = "Images"; +"lng_stickers_create_open_failed" = "Could not load image."; +"lng_stickers_create_too_small" = "The image must be at least {size} pixels on each side."; +"lng_stickers_create_choose_emoji" = "Choose an emoji that corresponds to your sticker:"; +"lng_stickers_create_emoji_about" = "Choose emojis that match your sticker"; +"lng_stickers_create_emoji_required" = "Please choose an emoji."; +"lng_stickers_create_uploading" = "Uploading sticker…"; +"lng_stickers_create_upload_failed" = "Sticker upload failed. Please try again."; +"lng_stickers_create_added" = "Sticker added."; + "lng_in_dlg_photo" = "Photo"; "lng_in_dlg_album" = "Album"; "lng_in_dlg_video" = "Video"; "lng_in_dlg_audio_file" = "Audio file"; "lng_in_dlg_contact" = "Contact"; "lng_in_dlg_audio" = "Voice message"; +"lng_in_dlg_audio_unread" = "{emoji} Voice message"; "lng_in_dlg_video_message" = "Video message"; "lng_in_dlg_voice_message_ttl" = "One-time Voice Message"; "lng_in_dlg_video_message_ttl" = "One-time Video Message"; @@ -5120,6 +5164,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_context_animated_reactions" = "Reactions contain emoji from **{name} pack**."; "lng_context_animated_reactions_many#one" = "Reactions contain emoji from **{count} pack**."; "lng_context_animated_reactions_many#other" = "Reactions contain emoji from **{count} packs**."; +"lng_context_animated_poll_option" = "This option contains emoji from **{name} pack**."; +"lng_context_animated_poll_option_many#one" = "This option contains emoji from **{count} pack**."; +"lng_context_animated_poll_option_many#other" = "This option contains emoji from **{count} packs**."; "lng_context_noforwards_info_channel" = "Copying and forwarding is not allowed in this channel."; "lng_context_noforwards_info_group" = "Copying and forwarding is not allowed in this group."; @@ -5597,6 +5644,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_mediaview_playback_speed" = "Playback speed: {speed}"; "lng_mediaview_rotate_video" = "Rotate video"; "lng_mediaview_quality_auto" = "Auto"; +"lng_mediaview_quality_original" = "Original ({quality}p)"; "lng_theme_preview_title" = "Theme Preview"; "lng_theme_preview_generating" = "Generating color theme preview..."; @@ -7170,10 +7218,20 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_photo_editor_menu_flip" = "Flip"; "lng_photo_editor_menu_duplicate" = "Duplicate"; +"lng_photo_editor_text_style_plain" = "Plain"; +"lng_photo_editor_text_style_framed" = "Framed"; +"lng_photo_editor_text_style_semi_transparent" = "Semi-Transparent"; + "lng_photo_editor_crop_original" = "Original"; "lng_photo_editor_crop_square" = "Square"; "lng_photo_editor_crop_free" = "Free"; +"lng_photo_editor_corners_about" = "Choose how rounded the sticker corners should look."; +"lng_photo_editor_corners_large" = "Large"; +"lng_photo_editor_corners_medium" = "Medium"; +"lng_photo_editor_corners_small" = "Small"; +"lng_photo_editor_corners_none" = "None"; + "lng_voice_speed_slow" = "Slow"; "lng_voice_speed_normal" = "Normal"; "lng_voice_speed_medium" = "Medium"; @@ -7838,6 +7896,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_ai_compose_apply_style" = "Apply Style"; "lng_ai_compose_style_tooltip" = "Choose Style"; +"lng_send_as_file_tooltip" = "Send text as a file."; + // Wnd specific "lng_wnd_choose_program_menu" = "Choose Default Program..."; @@ -7904,6 +7964,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_mac_hold_to_quit" = "Hold {text} to Quit"; +"lng_sr_country_column_name" = "Country name"; +"lng_sr_languages_column_native" = "Native name"; +"lng_sr_languages_column_name" = "Language name"; + // Keys finished diff --git a/Telegram/Resources/uwp/AppX/AppxManifest.xml b/Telegram/Resources/uwp/AppX/AppxManifest.xml index f602bb2280..b789e77d2f 100644 --- a/Telegram/Resources/uwp/AppX/AppxManifest.xml +++ b/Telegram/Resources/uwp/AppX/AppxManifest.xml @@ -10,7 +10,7 @@ + Version="6.7.8.0" /> Telegram Desktop Telegram Messenger LLP diff --git a/Telegram/Resources/winrc/Telegram.rc b/Telegram/Resources/winrc/Telegram.rc index fd9ae9da39..624e3839ac 100644 --- a/Telegram/Resources/winrc/Telegram.rc +++ b/Telegram/Resources/winrc/Telegram.rc @@ -44,8 +44,8 @@ IDI_ICON1 ICON "..\\art\\icon256.ico" // VS_VERSION_INFO VERSIONINFO - FILEVERSION 6,7,5,0 - PRODUCTVERSION 6,7,5,0 + FILEVERSION 6,7,8,0 + PRODUCTVERSION 6,7,8,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -62,10 +62,10 @@ BEGIN BEGIN VALUE "CompanyName", "Radolyn Labs" VALUE "FileDescription", "AyuGram Desktop" - VALUE "FileVersion", "6.7.5.0" + VALUE "FileVersion", "6.7.8.0" VALUE "LegalCopyright", "Copyright (C) 2014-2026" VALUE "ProductName", "AyuGram Desktop" - VALUE "ProductVersion", "6.7.5.0" + VALUE "ProductVersion", "6.7.8.0" END END BLOCK "VarFileInfo" diff --git a/Telegram/Resources/winrc/Updater.rc b/Telegram/Resources/winrc/Updater.rc index c7ddd59011..68a32b8cc5 100644 --- a/Telegram/Resources/winrc/Updater.rc +++ b/Telegram/Resources/winrc/Updater.rc @@ -35,8 +35,8 @@ LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US // VS_VERSION_INFO VERSIONINFO - FILEVERSION 6,7,5,0 - PRODUCTVERSION 6,7,5,0 + FILEVERSION 6,7,8,0 + PRODUCTVERSION 6,7,8,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -53,10 +53,10 @@ BEGIN BEGIN VALUE "CompanyName", "Radolyn Labs" VALUE "FileDescription", "AyuGram Desktop Updater" - VALUE "FileVersion", "6.7.5.0" + VALUE "FileVersion", "6.7.8.0" VALUE "LegalCopyright", "Copyright (C) 2014-2026" VALUE "ProductName", "AyuGram Desktop" - VALUE "ProductVersion", "6.7.5.0" + VALUE "ProductVersion", "6.7.8.0" END END BLOCK "VarFileInfo" diff --git a/Telegram/SourceFiles/api/api_chat_invite.cpp b/Telegram/SourceFiles/api/api_chat_invite.cpp index f1a3f81d1c..059cbfc850 100644 --- a/Telegram/SourceFiles/api/api_chat_invite.cpp +++ b/Telegram/SourceFiles/api/api_chat_invite.cpp @@ -39,6 +39,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "window/window_session_controller.h" #include "styles/style_boxes.h" #include "styles/style_chat_helpers.h" +#include "styles/style_color_indices.h" #include "styles/style_credits.h" #include "styles/style_info.h" #include "styles/style_layers.h" @@ -48,6 +49,69 @@ namespace Api { namespace { +struct InviteParticipant { + not_null user; + Ui::PeerUserpicView userpic; +}; + +struct ChatInvite { + QString title; + QString about; + PhotoData *photo = nullptr; + int participantsCount = 0; + std::vector participants; + bool isPublic = false; + bool isChannel = false; + bool isMegagroup = false; + bool isBroadcast = false; + bool isRequestNeeded = false; + bool isFake = false; + bool isScam = false; + bool isVerified = false; +}; + +[[nodiscard]] ChatInvite ParseInvite( + not_null session, + const MTPDchatInvite &data) { + auto participants = std::vector(); + if (const auto list = data.vparticipants()) { + participants.reserve(list->v.size()); + for (const auto &participant : list->v) { + if (const auto user = session->data().processUser(participant)) { + participants.push_back(InviteParticipant{ user }); + } + } + } + const auto photo = session->data().processPhoto(data.vphoto()); + return { + .title = qs(data.vtitle()), + .about = data.vabout().value_or_empty(), + .photo = (photo->isNull() ? nullptr : photo.get()), + .participantsCount = data.vparticipants_count().v, + .participants = std::move(participants), + .isPublic = data.is_public(), + .isChannel = data.is_channel(), + .isMegagroup = data.is_megagroup(), + .isBroadcast = data.is_broadcast(), + .isRequestNeeded = data.is_request_needed(), + .isFake = data.is_fake(), + .isScam = data.is_scam(), + .isVerified = data.is_verified(), + }; +} + +[[nodiscard]] Info::Profile::BadgeType BadgeForInvite( + const ChatInvite &invite) { + using Type = Info::Profile::BadgeType; + return invite.isVerified + ? Type::Verified + : invite.isScam + ? Type::Scam + : invite.isFake + ? Type::Fake + : Type::None; +} + void SubmitChatInvite( base::weak_ptr weak, not_null session, @@ -200,7 +264,7 @@ void ConfirmSubscriptionBox( } } else { state->photoEmpty = std::make_unique( - Ui::EmptyUserpic::UserpicColor(0), + Ui::EmptyUserpic::UserpicColor(st::colorIndexRed), name); } Ui::AddSkip(content); @@ -356,6 +420,207 @@ void ConfirmSubscriptionBox( box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); } +void ConfirmInviteBox( + not_null box, + not_null session, + const MTPDchatInvite *invitePtr, + ChannelData *invitePeekChannel, + Fn submit) { + auto invite = ParseInvite(session, *invitePtr); + const auto isChannel = invite.isChannel && !invite.isMegagroup; + const auto requestApprove = invite.isRequestNeeded; + const auto count = invite.participantsCount; + + struct State { + std::shared_ptr photoMedia; + std::unique_ptr photoEmpty; + std::vector participants; + }; + const auto state = box->lifetime().make_state(); + state->participants = std::move(invite.participants); + + const auto status = [&] { + return invitePeekChannel + ? tr::lng_channel_invite_private(tr::now) + : (!state->participants.empty() + && int(state->participants.size()) < count) + ? tr::lng_group_invite_members(tr::now, lt_count, count) + : (count > 0 && isChannel) + ? tr::lng_chat_status_subscribers( + tr::now, + lt_count_decimal, + count) + : (count > 0) + ? tr::lng_chat_status_members(tr::now, lt_count_decimal, count) + : isChannel + ? tr::lng_channel_status(tr::now) + : tr::lng_group_status(tr::now); + }(); + + box->setNoContentMargin(true); + box->setWidth(st::boxWideWidth); + const auto content = box->verticalLayout(); + + Ui::AddSkip(content, st::confirmInvitePhotoTop); + const auto userpic = content->add( + object_ptr(content), + style::al_top); + const auto photoSize = st::confirmInvitePhotoSize; + userpic->resize(Size(photoSize)); + userpic->setNaturalWidth(photoSize); + userpic->paintRequest( + ) | rpl::on_next([=, small = Data::PhotoSize::Small] { + auto p = QPainter(userpic); + if (state->photoMedia) { + if (const auto image = state->photoMedia->image(small)) { + p.drawPixmap( + 0, + 0, + image->pix( + Size(photoSize), + { .options = Images::Option::RoundCircle })); + } + } else if (state->photoEmpty) { + state->photoEmpty->paintCircle( + p, + 0, + 0, + userpic->width(), + photoSize); + } + }, userpic->lifetime()); + userpic->setAttribute(Qt::WA_TransparentForMouseEvents); + if (const auto photo = invite.photo) { + state->photoMedia = photo->createMediaView(); + state->photoMedia->wanted( + Data::PhotoSize::Small, + Data::FileOrigin()); + if (!state->photoMedia->image(Data::PhotoSize::Small)) { + session->downloaderTaskFinished( + ) | rpl::on_next([=] { + userpic->update(); + }, userpic->lifetime()); + } + } else { + state->photoEmpty = std::make_unique( + Ui::EmptyUserpic::UserpicColor(st::colorIndexRed), + invite.title); + } + + Ui::AddSkip(content); + const auto title = box->addRow( + object_ptr( + box, + invite.title, + st::confirmInviteTitle), + style::al_top); + + const auto badgeType = BadgeForInvite(invite); + if (badgeType != Info::Profile::BadgeType::None) { + const auto badgeParent = title->parentWidget(); + const auto badge = box->lifetime().make_state( + badgeParent, + st::infoPeerBadge, + session, + rpl::single(Info::Profile::Badge::Content{ badgeType }), + nullptr, + [] { return false; }); + title->geometryValue( + ) | rpl::on_next([=](const QRect &r) { + badge->move(r.x() + r.width(), r.y(), r.y() + r.height()); + }, title->lifetime()); + } + + box->addRow( + object_ptr( + box, + status, + st::confirmInviteStatus), + style::al_top); + + if (!invite.about.isEmpty()) { + box->addRow( + object_ptr( + box, + invite.about, + st::confirmInviteAbout), + st::confirmInviteAboutPadding, + style::al_top); + } + + if (requestApprove) { + box->addRow( + object_ptr( + box, + (isChannel + ? tr::lng_group_request_about_channel(tr::now) + : tr::lng_group_request_about(tr::now)), + st::confirmInviteStatus), + st::confirmInviteAboutRequestsPadding, + style::al_top); + } + + if (!state->participants.empty()) { + while (state->participants.size() > 4) { + state->participants.pop_back(); + } + const auto padding = (st::confirmInviteUsersWidth + - 4 * st::confirmInviteUserPhotoSize) / 10; + const auto userWidth = st::confirmInviteUserPhotoSize + 2 * padding; + + auto strip = object_ptr(content); + const auto rawStrip = strip.data(); + rawStrip->resize(st::boxWideWidth, st::confirmInviteUserHeight); + rawStrip->setNaturalWidth(st::boxWideWidth); + + const auto shown = int(state->participants.size()); + const auto sumWidth = shown * userWidth; + const auto baseLeft = (st::boxWideWidth - sumWidth) / 2; + for (auto i = 0; i != shown; ++i) { + const auto &participant = state->participants[i]; + const auto name = Ui::CreateChild( + rawStrip, + st::confirmInviteUserName); + name->resizeToWidth( + st::confirmInviteUserPhotoSize + padding); + name->setText(participant.user->firstName.isEmpty() + ? participant.user->name() + : participant.user->firstName); + name->moveToLeft( + baseLeft + i * userWidth + (padding / 2), + st::confirmInviteUserNameTop - st::confirmInviteUserPhotoTop); + } + + rawStrip->paintRequest( + ) | rpl::on_next([=] { + auto p = Painter(rawStrip); + const auto total = int(state->participants.size()); + const auto totalWidth = total * userWidth; + auto left = (rawStrip->width() - totalWidth) / 2; + for (auto &participant : state->participants) { + participant.user->paintUserpicLeft( + p, + participant.userpic, + left + (userWidth - st::confirmInviteUserPhotoSize) / 2, + 0, + rawStrip->width(), + st::confirmInviteUserPhotoSize); + left += userWidth; + } + }, rawStrip->lifetime()); + + Ui::AddSkip(content, st::boxPadding.bottom()); + content->add(std::move(strip), style::margins()); + } + + box->addButton((requestApprove + ? tr::lng_group_request_to_join() + : isChannel + ? tr::lng_profile_join_channel() + : tr::lng_profile_join_group()), submit); + box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); +} + } // namespace void CheckChatInvite( @@ -399,9 +664,10 @@ void CheckChatInvite( session, hash, &data)) - : strong->show(Box( + : strong->show(Box( + ConfirmInviteBox, session, - data, + &data, invitePeekChannel, [=] { SubmitChatInvite(weak, session, hash, isGroup); })); if (invitePeekChannel) { @@ -450,255 +716,3 @@ void CheckChatInvite( } } // namespace Api - -struct ConfirmInviteBox::Participant { - not_null user; - Ui::PeerUserpicView userpic; -}; - -ConfirmInviteBox::ConfirmInviteBox( - QWidget*, - not_null session, - const MTPDchatInvite &data, - ChannelData *invitePeekChannel, - Fn submit) -: ConfirmInviteBox( - session, - Parse(session, data), - invitePeekChannel, - std::move(submit)) { -} - -ConfirmInviteBox::ConfirmInviteBox( - not_null session, - ChatInvite &&invite, - ChannelData *invitePeekChannel, - Fn submit) -: _session(session) -, _submit(std::move(submit)) -, _title(this, st::confirmInviteTitle) -, _badge(std::make_unique( - this, - st::infoPeerBadge, - _session, - rpl::single(Info::Profile::Badge::Content{ BadgeForInvite(invite) }), - nullptr, - [=] { return false; })) -, _status(this, st::confirmInviteStatus) -, _about(this, st::confirmInviteAbout) -, _aboutRequests(this, st::confirmInviteStatus) -, _participants(std::move(invite.participants)) -, _isChannel(invite.isChannel && !invite.isMegagroup) -, _requestApprove(invite.isRequestNeeded) { - const auto count = invite.participantsCount; - const auto status = [&] { - return invitePeekChannel - ? tr::lng_channel_invite_private(tr::now) - : (!_participants.empty() && _participants.size() < count) - ? tr::lng_group_invite_members(tr::now, lt_count, count) - : (count > 0 && _isChannel) - ? tr::lng_chat_status_subscribers( - tr::now, - lt_count_decimal, - count) - : (count > 0) - ? tr::lng_chat_status_members(tr::now, lt_count_decimal, count) - : _isChannel - ? tr::lng_channel_status(tr::now) - : tr::lng_group_status(tr::now); - }(); - _title->setText(invite.title); - _status->setText(status); - if (!invite.about.isEmpty()) { - _about->setText(invite.about); - } else { - _about.destroy(); - } - if (_requestApprove) { - _aboutRequests->setText(_isChannel - ? tr::lng_group_request_about_channel(tr::now) - : tr::lng_group_request_about(tr::now)); - } else { - _aboutRequests.destroy(); - } - - if (invite.photo) { - _photo = invite.photo->createMediaView(); - _photo->wanted(Data::PhotoSize::Small, Data::FileOrigin()); - if (!_photo->image(Data::PhotoSize::Small)) { - _session->downloaderTaskFinished( - ) | rpl::on_next([=] { - update(); - }, lifetime()); - } - } else { - _photoEmpty = std::make_unique( - Ui::EmptyUserpic::UserpicColor(0), - invite.title); - } -} - -ConfirmInviteBox::~ConfirmInviteBox() = default; - -ConfirmInviteBox::ChatInvite ConfirmInviteBox::Parse( - not_null session, - const MTPDchatInvite &data) { - auto participants = std::vector(); - if (const auto list = data.vparticipants()) { - participants.reserve(list->v.size()); - for (const auto &participant : list->v) { - if (const auto user = session->data().processUser(participant)) { - participants.push_back(Participant{ user }); - } - } - } - const auto photo = session->data().processPhoto(data.vphoto()); - return { - .title = qs(data.vtitle()), - .about = data.vabout().value_or_empty(), - .photo = (photo->isNull() ? nullptr : photo.get()), - .participantsCount = data.vparticipants_count().v, - .participants = std::move(participants), - .isPublic = data.is_public(), - .isChannel = data.is_channel(), - .isMegagroup = data.is_megagroup(), - .isBroadcast = data.is_broadcast(), - .isRequestNeeded = data.is_request_needed(), - .isFake = data.is_fake(), - .isScam = data.is_scam(), - .isVerified = data.is_verified(), - }; -} - -[[nodiscard]] Info::Profile::BadgeType ConfirmInviteBox::BadgeForInvite( - const ChatInvite &invite) { - using Type = Info::Profile::BadgeType; - return invite.isVerified - ? Type::Verified - : invite.isScam - ? Type::Scam - : invite.isFake - ? Type::Fake - : Type::None; -} - -void ConfirmInviteBox::prepare() { - addButton( - (_requestApprove - ? tr::lng_group_request_to_join() - : _isChannel - ? tr::lng_profile_join_channel() - : tr::lng_profile_join_group()), - _submit); - addButton(tr::lng_cancel(), [=] { closeBox(); }); - - while (_participants.size() > 4) { - _participants.pop_back(); - } - - auto newHeight = st::confirmInviteStatusTop + _status->height() + st::boxPadding.bottom(); - if (!_participants.empty()) { - int skip = (st::confirmInviteUsersWidth - 4 * st::confirmInviteUserPhotoSize) / 5; - int padding = skip / 2; - _userWidth = (st::confirmInviteUserPhotoSize + 2 * padding); - int sumWidth = _participants.size() * _userWidth; - int left = (st::boxWideWidth - sumWidth) / 2; - for (const auto &participant : _participants) { - auto name = new Ui::FlatLabel(this, st::confirmInviteUserName); - name->resizeToWidth(st::confirmInviteUserPhotoSize + padding); - name->setText(participant.user->firstName.isEmpty() - ? participant.user->name() - : participant.user->firstName); - name->moveToLeft(left + (padding / 2), st::confirmInviteUserNameTop); - left += _userWidth; - } - - newHeight += st::confirmInviteUserHeight; - } - if (_about) { - const auto padding = st::confirmInviteAboutPadding; - _about->resizeToWidth(st::boxWideWidth - padding.left() - padding.right()); - newHeight += padding.top() + _about->height() + padding.bottom(); - } - if (_aboutRequests) { - const auto padding = st::confirmInviteAboutRequestsPadding; - _aboutRequests->resizeToWidth(st::boxWideWidth - padding.left() - padding.right()); - newHeight += padding.top() + _aboutRequests->height() + padding.bottom(); - } - setDimensions(st::boxWideWidth, newHeight); -} - -void ConfirmInviteBox::resizeEvent(QResizeEvent *e) { - BoxContent::resizeEvent(e); - - const auto padding = st::boxRowPadding; - auto nameWidth = width() - padding.left() - padding.right(); - auto badgeWidth = 0; - if (const auto widget = _badge->widget()) { - badgeWidth = st::infoVerifiedCheckPosition.x() + widget->width(); - nameWidth -= badgeWidth; - } - _title->resizeToWidth(std::min(nameWidth, _title->textMaxWidth())); - _title->moveToLeft( - (width() - _title->width() - badgeWidth) / 2, - st::confirmInviteTitleTop); - const auto badgeLeft = _title->x() + _title->width(); - const auto badgeTop = _title->y(); - const auto badgeBottom = _title->y() + _title->height(); - _badge->move(badgeLeft, badgeTop, badgeBottom); - - _status->move( - (width() - _status->width()) / 2, - st::confirmInviteStatusTop); - auto bottom = _status->y() - + _status->height() - + st::boxPadding.bottom() - + (_participants.empty() ? 0 : st::confirmInviteUserHeight); - if (_about) { - const auto padding = st::confirmInviteAboutPadding; - _about->move((width() - _about->width()) / 2, bottom + padding.top()); - bottom += padding.top() + _about->height() + padding.bottom(); - } - if (_aboutRequests) { - const auto padding = st::confirmInviteAboutRequestsPadding; - _aboutRequests->move((width() - _aboutRequests->width()) / 2, bottom + padding.top()); - } -} - -void ConfirmInviteBox::paintEvent(QPaintEvent *e) { - BoxContent::paintEvent(e); - - Painter p(this); - - if (_photo) { - if (const auto image = _photo->image(Data::PhotoSize::Small)) { - const auto size = st::confirmInvitePhotoSize; - p.drawPixmap( - (width() - size) / 2, - st::confirmInvitePhotoTop, - image->pix( - { size, size }, - { .options = Images::Option::RoundCircle })); - } - } else if (_photoEmpty) { - _photoEmpty->paintCircle( - p, - (width() - st::confirmInvitePhotoSize) / 2, - st::confirmInvitePhotoTop, - width(), - st::confirmInvitePhotoSize); - } - - int sumWidth = _participants.size() * _userWidth; - int left = (width() - sumWidth) / 2; - for (auto &participant : _participants) { - participant.user->paintUserpicLeft( - p, - participant.userpic, - left + (_userWidth - st::confirmInviteUserPhotoSize) / 2, - st::confirmInviteUserPhotoTop, - width(), - st::confirmInviteUserPhotoSize); - left += _userWidth; - } -} diff --git a/Telegram/SourceFiles/api/api_chat_invite.h b/Telegram/SourceFiles/api/api_chat_invite.h index 7ae7114243..dbd5a43321 100644 --- a/Telegram/SourceFiles/api/api_chat_invite.h +++ b/Telegram/SourceFiles/api/api_chat_invite.h @@ -7,16 +7,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once -#include "ui/layers/box_content.h" - -class UserData; class ChannelData; -namespace Info::Profile { -class Badge; -enum class BadgeType : ushort; -} // namespace Info::Profile - namespace Main { class Session; } // namespace Main @@ -25,14 +17,6 @@ namespace Window { class SessionController; } // namespace Window -namespace Data { -class PhotoMedia; -} // namespace Data - -namespace Ui { -class EmptyUserpic; -} // namespace Ui - namespace Api { void CheckChatInvite( @@ -42,67 +26,3 @@ void CheckChatInvite( Fn loaded = nullptr); } // namespace Api - -class ConfirmInviteBox final : public Ui::BoxContent { -public: - ConfirmInviteBox( - QWidget*, - not_null session, - const MTPDchatInvite &data, - ChannelData *invitePeekChannel, - Fn submit); - ~ConfirmInviteBox(); - -protected: - void prepare() override; - - void resizeEvent(QResizeEvent *e) override; - void paintEvent(QPaintEvent *e) override; - -private: - struct Participant; - struct ChatInvite { - QString title; - QString about; - PhotoData *photo = nullptr; - int participantsCount = 0; - std::vector participants; - bool isPublic = false; - bool isChannel = false; - bool isMegagroup = false; - bool isBroadcast = false; - bool isRequestNeeded = false; - bool isFake = false; - bool isScam = false; - bool isVerified = false; - }; - [[nodiscard]] static ChatInvite Parse( - not_null session, - const MTPDchatInvite &data); - [[nodiscard]] Info::Profile::BadgeType BadgeForInvite( - const ChatInvite &invite); - - ConfirmInviteBox( - not_null session, - ChatInvite &&invite, - ChannelData *invitePeekChannel, - Fn submit); - - const not_null _session; - - Fn _submit; - object_ptr _title; - std::unique_ptr _badge; - object_ptr _status; - object_ptr _about; - object_ptr _aboutRequests; - std::shared_ptr _photo; - std::unique_ptr _photoEmpty; - std::vector _participants; - - bool _isChannel = false; - bool _requestApprove = false; - - int _userWidth = 0; - -}; diff --git a/Telegram/SourceFiles/api/api_credits.h b/Telegram/SourceFiles/api/api_credits.h index 1b5745e42b..0ebe857a78 100644 --- a/Telegram/SourceFiles/api/api_credits.h +++ b/Telegram/SourceFiles/api/api_credits.h @@ -111,8 +111,6 @@ private: const bool _isUser = false; Data::CreditsEarnStatistics _data; - mtpRequestId _requestId = 0; - }; [[nodiscard]] rpl::producer> PremiumPeerBot( diff --git a/Telegram/SourceFiles/api/api_messages_search.cpp b/Telegram/SourceFiles/api/api_messages_search.cpp index 13a0e96bf7..ac348199bc 100644 --- a/Telegram/SourceFiles/api/api_messages_search.cpp +++ b/Telegram/SourceFiles/api/api_messages_search.cpp @@ -58,9 +58,23 @@ constexpr auto kSearchPerPage = 50; result += u"emoji"_q + tag.emoji(); } } + switch (request.filter) { + case SearchFilter::NoFilter: break; + case SearchFilter::Pinned: result += u"\npinned"_q; break; + } return result; } +[[nodiscard]] MTPMessagesFilter PrepareFilter(SearchFilter filter) { + switch (filter) { + case SearchFilter::Pinned: + return MTP_inputMessagesFilterPinned(); + case SearchFilter::NoFilter: + return MTP_inputMessagesFilterEmpty(); + } + return MTP_inputMessagesFilterEmpty(); +} + } // namespace MessagesSearch::MessagesSearch(not_null history) @@ -113,7 +127,7 @@ void MessagesSearch::searchRequest() { Data::ReactionToMTP )), MTP_int(_request.topMsgId), // top_msg_id - MTP_inputMessagesFilterEmpty(), + PrepareFilter(_request.filter), MTP_int(0), // min_date MTP_int(0), // max_date MTP_int(_offsetId), // offset_id diff --git a/Telegram/SourceFiles/api/api_messages_search.h b/Telegram/SourceFiles/api/api_messages_search.h index b650b87994..99b222b703 100644 --- a/Telegram/SourceFiles/api/api_messages_search.h +++ b/Telegram/SourceFiles/api/api_messages_search.h @@ -20,6 +20,11 @@ struct ReactionId; namespace Api { +enum class SearchFilter { + NoFilter, + Pinned, +}; + struct FoundMessages { int total = -1; MessageIdsList messages; @@ -33,6 +38,7 @@ public: PeerData *from = nullptr; std::vector tags; MsgId topMsgId; + SearchFilter filter = SearchFilter::NoFilter; friend inline bool operator==( const Request &, diff --git a/Telegram/SourceFiles/api/api_statistics.h b/Telegram/SourceFiles/api/api_statistics.h index b419b8841c..b60dd3d28f 100644 --- a/Telegram/SourceFiles/api/api_statistics.h +++ b/Telegram/SourceFiles/api/api_statistics.h @@ -75,8 +75,6 @@ private: Data::PublicForwardsSlice _firstSlice; - mtpRequestId _requestId = 0; - }; class EarnStatistics final : public StatisticsRequestSender { diff --git a/Telegram/SourceFiles/api/api_stickers_creator.cpp b/Telegram/SourceFiles/api/api_stickers_creator.cpp new file mode 100644 index 0000000000..5b1b85d371 --- /dev/null +++ b/Telegram/SourceFiles/api/api_stickers_creator.cpp @@ -0,0 +1,543 @@ +/* +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_stickers_creator.h" + +#include "apiwrap.h" +#include "base/random.h" +#include "base/unixtime.h" +#include "chat_helpers/compose/compose_show.h" +#include "data/data_document.h" +#include "data/data_file_origin.h" +#include "data/data_session.h" +#include "data/stickers/data_stickers.h" +#include "data/stickers/data_stickers_set.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "menu/menu_action_with_thumbnail.h" +#include "storage/file_upload.h" +#include "storage/localimageloader.h" +#include "styles/style_menu_icons.h" +#include "styles/style_widgets.h" +#include "ui/dynamic_thumbnails.h" +#include "ui/widgets/menu/menu_add_action_callback.h" +#include "ui/widgets/menu/menu_common.h" +#include "ui/widgets/popup_menu.h" + +namespace Api { +namespace { + +constexpr auto kStickerSide = 512; + +[[nodiscard]] MTPInputStickerSetItem InputItem( + const MTPInputDocument &document, + const QString &emoji) { + return MTP_inputStickerSetItem( + MTP_flags(0), + document, + MTP_string(emoji), + MTPMaskCoords(), + MTPstring()); +} + +[[nodiscard]] std::shared_ptr PrepareStickerWebp( + MTP::DcId dcId, + DocumentId id, + const QByteArray &bytes) { + const auto filename = u"sticker.webp"_q; + auto attributes = QVector( + 1, + MTP_documentAttributeFilename(MTP_string(filename))); + attributes.push_back(MTP_documentAttributeImageSize( + MTP_int(kStickerSide), + MTP_int(kStickerSide))); + + auto result = MakePreparedFile({ + .id = id, + .type = SendMediaType::File, + }); + result->filename = filename; + result->filemime = u"image/webp"_q; + result->content = bytes; + result->filesize = bytes.size(); + result->setFileData(bytes); + result->document = MTP_document( + MTP_flags(0), + MTP_long(id), + MTP_long(0), + MTP_bytes(), + MTP_int(base::unixtime::now()), + MTP_string("image/webp"), + MTP_long(bytes.size()), + MTP_vector(), + MTPVector(), + MTP_int(dcId), + MTP_vector(std::move(attributes))); + return result; +} + +void FeedSetIfFull( + not_null session, + const MTPmessages_StickerSet &result) { + result.match([&](const MTPDmessages_stickerSet &data) { + session->data().stickers().feedSetFull(data); + session->data().stickers().notifyUpdated( + Data::StickersType::Stickers); + }, [](const auto &) { + }); +} + +template +void EnumerateOwnedSets( + not_null session, + Data::StickersType type, + Callback &&callback) { + const auto &stickers = session->data().stickers(); + const auto &sets = stickers.sets(); + const auto &order = (type == Data::StickersType::Emoji) + ? stickers.emojiSetsOrder() + : stickers.setsOrder(); + for (const auto setId : order) { + const auto it = sets.find(setId); + if (it == sets.end()) { + continue; + } + const auto set = it->second.get(); + if (!(set->flags & Data::StickersSetFlag::AmCreator) + || (set->type() != type)) { + continue; + } + using namespace Data; + if constexpr (std::is_same_v< + bool, + std::invoke_result_t>>) { + if (!callback(set)) { + return; + } + } else { + callback(set); + } + } +} + +void FillChooseOwnedSetMenu( + not_null menu, + std::shared_ptr show, + not_null document, + Data::StickersType type) { + const auto session = &show->session(); + const auto emoji = StickerEmojiOrDefault(document); + const auto isEmoji = (type == Data::StickersType::Emoji); + const auto maxCount = isEmoji + ? kEmojiInOwnedSetMax + : kStickersInOwnedSetMax; + const auto fullMessage = isEmoji + ? tr::lng_emoji_set_is_full + : tr::lng_stickers_set_is_full; + const auto addedMessage = isEmoji + ? tr::lng_emoji_added + : tr::lng_stickers_create_added; + const auto alreadyMessage = isEmoji + ? tr::lng_emoji_already_in_set + : tr::lng_stickers_already_in_set; + const auto failToast = [=](QString err) { + show->showToast(err.isEmpty() + ? tr::lng_attach_failed(tr::now) + : err); + }; + EnumerateOwnedSets(session, type, [&](not_null set) { + const auto identifier = set->identifier(); + const auto coverDocument = set->lookupThumbnailDocument(); + auto thumbnail = coverDocument + ? Ui::MakeDocumentThumbnail( + coverDocument, + Data::FileOriginStickerSet(set->id, set->accessHash)) + : nullptr; + const auto targetSetId = set->id; + const auto handler = crl::guard(session, [=] { + const auto &map = session->data().stickers().sets(); + const auto i = map.find(targetSetId); + if (i != map.end() && i->second->count >= maxCount) { + show->showToast(fullMessage(tr::now)); + return; + } + const auto oldCount = (i != map.end()) + ? i->second->count + : 0; + AddExistingStickerToSet( + session, + identifier, + document, + emoji, + crl::guard(session, [=](MTPmessages_StickerSet) { + const auto &map = session->data().stickers().sets(); + const auto i = map.find(targetSetId); + const auto newCount = (i != map.end()) + ? i->second->count + : oldCount; + show->showToast(newCount > oldCount + ? addedMessage(tr::now) + : alreadyMessage(tr::now)); + }), + crl::guard(session, failToast)); + }); + const auto rawAction = Ui::Menu::CreateAction( + menu.get(), + set->title, + handler); + auto item = base::make_unique_q( + menu->menu(), + menu->menu()->st(), + rawAction, + std::move(thumbnail), + st::menuIconStickerAdd.width()); + menu->addAction(std::move(item)); + }); +} + +} // namespace + +void AddExistingStickerToSet( + not_null session, + const StickerSetIdentifier &set, + not_null document, + const QString &emoji, + Fn done, + Fn fail) { + session->api().request(MTPstickers_AddStickerToSet( + Data::InputStickerSet(set), + InputItem(document->mtpInput(), emoji)) + ).done([=](const MTPmessages_StickerSet &result) { + FeedSetIfFull(session, result); + if (done) { + done(result); + } + }).fail([=](const MTP::Error &error) { + if (fail) { + fail(error.type()); + } + }).handleFloodErrors().send(); +} + +QString StickerEmojiOrDefault(not_null document) { + if (const auto sticker = document->sticker()) { + if (!sticker->alt.isEmpty()) { + return sticker->alt; + } + } + return QString::fromUtf8("\xF0\x9F\x99\x82"); +} + +bool HasOwnedStickerSets(not_null session) { + auto found = false; + EnumerateOwnedSets( + session, + Data::StickersType::Stickers, + [&](not_null) { + found = true; + return false; + }); + return found; +} + +bool HasOwnedEmojiSets(not_null session) { + auto found = false; + EnumerateOwnedSets( + session, + Data::StickersType::Emoji, + [&](not_null) { + found = true; + return false; + }); + return found; +} + +void FillChooseStickerSetMenu( + not_null menu, + std::shared_ptr show, + not_null document) { + using namespace Data; + FillChooseOwnedSetMenu(menu, show, document, StickersType::Stickers); +} + +void FillChooseEmojiSetMenu( + not_null menu, + std::shared_ptr show, + not_null document) { + FillChooseOwnedSetMenu(menu, show, document, Data::StickersType::Emoji); +} + +void AddAddToStickerSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document) { + const auto session = &show->session(); + if (!HasOwnedStickerSets(session)) { + return; + } + addAction({ + .text = tr::lng_stickers_add_to_set(tr::now), + .icon = &st::menuIconStickerAdd, + .fillSubmenu = [show, document](not_null submenu) { + FillChooseStickerSetMenu(submenu, show, document); + }, + .submenuSt = &st::popupMenuWithIcons, + }); +} + +void AddAddToEmojiSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document) { + const auto session = &show->session(); + if (!HasOwnedEmojiSets(session)) { + return; + } + addAction({ + .text = tr::lng_emoji_add_to_set(tr::now), + .icon = &st::menuIconEmoji, + .fillSubmenu = [show, document](not_null submenu) { + FillChooseEmojiSetMenu(submenu, show, document); + }, + .submenuSt = &st::popupMenuWithIcons, + }); +} + +void AddAddToOwnedSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document) { + const auto sticker = document->sticker(); + const auto isLottie = sticker && sticker->isLottie(); + const auto size = document->dimensions; + const auto fitsEmoji = !size.isEmpty() + && (size.width() <= kEmojiStickerSideMax) + && (size.height() <= kEmojiStickerSideMax); + if (isLottie || !fitsEmoji) { + AddAddToStickerSetAction(addAction, show, document); + } + if (isLottie || fitsEmoji) { + AddAddToEmojiSetAction(addAction, std::move(show), document); + } +} + +void DeleteStickerSet( + not_null session, + const StickerSetIdentifier &set, + Fn done, + Fn fail) { + session->api().request(MTPstickers_DeleteStickerSet( + Data::InputStickerSet(set)) + ).done([=] { + session->data().stickers().notifyUpdated( + Data::StickersType::Stickers); + if (done) { + done(); + } + }).fail([=](const MTP::Error &error) { + if (fail) { + fail(error.type()); + } + }).send(); +} + +StickerUpload::StickerUpload( + not_null session, + StickerSetIdentifier set, + QByteArray webpBytes, + QString emoji) +: _session(session) +, _set(std::move(set)) +, _bytes(std::move(webpBytes)) +, _emoji(std::move(emoji)) +, _api(&session->mtp()) { +} + +StickerUpload::~StickerUpload() { + cancel(); +} + +void StickerUpload::start( + Fn done, + Fn fail, + Fn progress) { + Expects(!_uploadId); + + _done = std::move(done); + _fail = std::move(fail); + _progress = std::move(progress); + + _documentId = base::RandomValue(); + auto ready = PrepareStickerWebp( + _session->mtp().mainDcId(), + _documentId, + _bytes); + _uploadId = FullMsgId( + _session->userPeerId(), + _session->data().nextLocalMessageId()); + + const auto document = _session->data().document(_documentId); + document->uploadingData = std::make_unique( + document->size > 0 ? document->size : int64(_bytes.size())); + + _session->uploader().documentReady( + ) | rpl::filter([=](const Storage::UploadedMedia &data) { + return data.fullId == _uploadId; + }) | rpl::on_next([=](const Storage::UploadedMedia &data) { + uploadReady(data.info.file); + }, _uploadLifetime); + + _session->uploader().documentFailed( + ) | rpl::filter([=](const FullMsgId &id) { + return id == _uploadId; + }) | rpl::on_next([=] { + uploadFailed(); + }, _uploadLifetime); + + if (_progress) { + _session->uploader().documentProgress( + ) | rpl::filter([=](const FullMsgId &id) { + return id == _uploadId; + }) | rpl::on_next([=] { + uploadProgressed(); + }, _uploadLifetime); + } + + _session->uploader().upload(_uploadId, ready); +} + +void StickerUpload::cancel() { + if (_uploadId) { + _session->uploader().cancel(_uploadId); + _uploadId = FullMsgId(); + } + if (_addRequestId) { + _api.request(_addRequestId).cancel(); + _addRequestId = 0; + } + _uploadLifetime.destroy(); + _done = nullptr; + _fail = nullptr; + _progress = nullptr; +} + +void StickerUpload::uploadProgressed() { + if (!_progress) { + return; + } + const auto document = _session->data().document(_documentId); + if (!document->uploading() || !document->uploadingData) { + return; + } + const auto size = document->uploadingData->size; + if (size <= 0) { + return; + } + const auto percent = int( + (document->uploadingData->offset * 100) / size); + if (percent != _lastReportedPercent) { + _lastReportedPercent = percent; + _progress(percent); + } +} + +void StickerUpload::uploadFailed() { + const auto fail = std::move(_fail); + cancel(); + if (fail) { + fail(QString()); + } +} + +void StickerUpload::uploadReady(const MTPInputFile &file) { + _uploadLifetime.destroy(); + _uploadId = FullMsgId(); + + auto attributes = QVector(); + attributes.push_back(MTP_documentAttributeSticker( + MTP_flags(0), + MTP_string(_emoji), + MTP_inputStickerSetEmpty(), + MTPMaskCoords())); + attributes.push_back(MTP_documentAttributeImageSize( + MTP_int(kStickerSide), + MTP_int(kStickerSide))); + + const auto media = MTP_inputMediaUploadedDocument( + MTP_flags(0), + file, + MTPInputFile(), + MTP_string("image/webp"), + MTP_vector(std::move(attributes)), + MTP_vector(), + MTPInputPhoto(), + MTP_int(0), + MTP_int(0)); + + _addRequestId = _api.request(MTPmessages_UploadMedia( + MTP_flags(0), + MTPstring(), + MTP_inputPeerSelf(), + media + )).done(crl::guard(this, [=](const MTPMessageMedia &result) { + _addRequestId = 0; + auto inputDoc = (MTPInputDocument*)(nullptr); + auto storage = MTPInputDocument(); + result.match([&](const MTPDmessageMediaDocument &data) { + if (const auto doc = data.vdocument()) { + doc->match([&](const MTPDdocument &d) { + storage = MTP_inputDocument( + d.vid(), + d.vaccess_hash(), + d.vfile_reference()); + inputDoc = &storage; + }, [](const auto &) { + }); + } + }, [](const auto &) { + }); + if (inputDoc) { + requestAddSticker(*inputDoc); + } else if (const auto fail = std::move(_fail)) { + cancel(); + fail(QString()); + } + })).fail(crl::guard(this, [=](const MTP::Error &error) { + _addRequestId = 0; + const auto fail = std::move(_fail); + const auto type = error.type(); + cancel(); + if (fail) { + fail(type); + } + })).handleFloodErrors().send(); +} + +void StickerUpload::requestAddSticker(const MTPInputDocument &document) { + _addRequestId = _api.request(MTPstickers_AddStickerToSet( + Data::InputStickerSet(_set), + InputItem(document, _emoji)) + ).done(crl::guard(this, [=](const MTPmessages_StickerSet &result) { + _addRequestId = 0; + FeedSetIfFull(_session, result); + const auto done = std::move(_done); + cancel(); + if (done) { + done(result); + } + })).fail(crl::guard(this, [=](const MTP::Error &error) { + _addRequestId = 0; + const auto fail = std::move(_fail); + const auto type = error.type(); + cancel(); + if (fail) { + fail(type); + } + })).handleFloodErrors().send(); +} + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_stickers_creator.h b/Telegram/SourceFiles/api/api_stickers_creator.h new file mode 100644 index 0000000000..069766854d --- /dev/null +++ b/Telegram/SourceFiles/api/api_stickers_creator.h @@ -0,0 +1,121 @@ +/* +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/weak_ptr.h" +#include "data/stickers/data_stickers.h" +#include "mtproto/sender.h" + +class DocumentData; + +namespace ChatHelpers { +class Show; +} // namespace ChatHelpers + +namespace Main { +class Session; +} // namespace Main + +namespace Ui { +class PopupMenu; +namespace Menu { +struct MenuCallback; +} // namespace Menu +} // namespace Ui + +namespace Api { + +inline constexpr auto kStickersInOwnedSetMax = 120; +inline constexpr auto kEmojiInOwnedSetMax = 200; +inline constexpr auto kEmojiStickerSideMax = 100; + +void AddExistingStickerToSet( + not_null session, + const StickerSetIdentifier &set, + not_null document, + const QString &emoji, + Fn done, + Fn fail); + +void DeleteStickerSet( + not_null session, + const StickerSetIdentifier &set, + Fn done, + Fn fail); + +[[nodiscard]] bool HasOwnedStickerSets(not_null session); +[[nodiscard]] bool HasOwnedEmojiSets(not_null session); + +[[nodiscard]] QString StickerEmojiOrDefault( + not_null document); + +void FillChooseStickerSetMenu( + not_null menu, + std::shared_ptr show, + not_null document); + +void FillChooseEmojiSetMenu( + not_null menu, + std::shared_ptr show, + not_null document); + +void AddAddToStickerSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document); + +void AddAddToEmojiSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document); + +void AddAddToOwnedSetAction( + const Ui::Menu::MenuCallback &addAction, + std::shared_ptr show, + not_null document); + +class StickerUpload final : public base::has_weak_ptr { +public: + StickerUpload( + not_null session, + StickerSetIdentifier set, + QByteArray webpBytes, + QString emoji); + ~StickerUpload(); + + void start( + Fn done, + Fn fail, + Fn progress = nullptr); + + void cancel(); + +private: + void uploadReady(const MTPInputFile &file); + void uploadFailed(); + void uploadProgressed(); + void requestAddSticker(const MTPInputDocument &document); + + const not_null _session; + StickerSetIdentifier _set; + QByteArray _bytes; + QString _emoji; + MTP::Sender _api; + rpl::lifetime _uploadLifetime; + FullMsgId _uploadId; + DocumentId _documentId = 0; + mtpRequestId _addRequestId = 0; + + Fn _done; + Fn _fail; + Fn _progress; + int _lastReportedPercent = -1; + +}; + +} // namespace Api diff --git a/Telegram/SourceFiles/boxes/boxes.style b/Telegram/SourceFiles/boxes/boxes.style index 56d0f48529..ba6f79f17d 100644 --- a/Telegram/SourceFiles/boxes/boxes.style +++ b/Telegram/SourceFiles/boxes/boxes.style @@ -59,6 +59,7 @@ boxPhotoTitlePosition: point(28px, 20px); boxPhotoPadding: margins(28px, 28px, 28px, 18px); boxPhotoCompressedSkip: 20px; boxPhotoCaptionSkip: 8px; +boxPhotoCaptionReplyOverlap: 5px; defaultChangeUserpicIcon: icon {{ "new_chat_photo", activeButtonFg }}; defaultUploadUserpicIcon: icon {{ "upload_chat_photo", msgDateImgFg }}; @@ -92,7 +93,7 @@ restoreUserpicIcon: UserpicButton(defaultUserpicButton) { } confirmInviteTitle: FlatLabel(defaultFlatLabel) { - align: align(center); + align: align(top); minWidth: 320px; maxHeight: 24px; textFg: windowBoldFg; @@ -101,9 +102,8 @@ confirmInviteTitle: FlatLabel(defaultFlatLabel) { } } confirmInviteAbout: FlatLabel(boxLabel) { - align: align(center); + align: align(top); minWidth: 320px; - maxHeight: 60px; style: TextStyle(boxLabelStyle) { lineHeight: 19px; } diff --git a/Telegram/SourceFiles/boxes/compose_ai_box.cpp b/Telegram/SourceFiles/boxes/compose_ai_box.cpp index 8b20695ee0..0d51c9d322 100644 --- a/Telegram/SourceFiles/boxes/compose_ai_box.cpp +++ b/Telegram/SourceFiles/boxes/compose_ai_box.cpp @@ -1203,6 +1203,10 @@ void ComposeAiContent::request() { return; } weak->_requestId = 0; + if (MTP::IgnoreError(error)) { + weak->resetState(CardState::Waiting); + return; + } weak->showError(error.type()); }); } diff --git a/Telegram/SourceFiles/boxes/connection_box.cpp b/Telegram/SourceFiles/boxes/connection_box.cpp index 1bdb77d824..fd0be2317a 100644 --- a/Telegram/SourceFiles/boxes/connection_box.cpp +++ b/Telegram/SourceFiles/boxes/connection_box.cpp @@ -18,6 +18,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "main/main_account.h" #include "main/main_session.h" #include "mtproto/facade.h" +#include "mtproto/mtproto_config.h" +#include "mtproto/proxy_check.h" +#include "qr/qr_generate.h" #include "settings/settings_common.h" #include "storage/localstorage.h" #include "ui/basic_click_handlers.h" @@ -32,6 +35,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/buttons.h" #include "ui/widgets/checkbox.h" #include "ui/widgets/dropdown_menu.h" +#include "ui/widgets/discrete_sliders.h" #include "ui/widgets/fields/input_field.h" #include "ui/widgets/fields/number_input.h" #include "ui/widgets/fields/password_input.h" @@ -46,10 +50,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/ui_utility.h" #include "boxes/abstract_box.h" // Ui::show(). #include "window/window_session_controller.h" -#include "styles/style_layers.h" #include "styles/style_boxes.h" #include "styles/style_chat_helpers.h" #include "styles/style_info.h" +#include "styles/style_intro.h" +#include "styles/style_layers.h" #include "styles/style_menu_icons.h" #include "styles/style_settings.h" @@ -62,22 +67,55 @@ constexpr auto kSaveSettingsDelayedTimeout = crl::time(1000); using ProxyData = MTP::ProxyData; -[[nodiscard]] std::vector ExtractUrlsSimple(const QString &input) { +[[nodiscard]] int ClosestProxyRotationTimeoutSection(int value) { + auto result = 0; + auto bestDistance = 0; + for (auto i = 0; i != int(Core::SettingsProxy::kProxyRotationTimeouts.size()); ++i) { + const auto current = Core::SettingsProxy::kProxyRotationTimeouts[i]; + const auto distance = (current > value) ? (current - value) : (value - current); + if ((i == 0) || (distance < bestDistance)) { + result = i; + bestDistance = distance; + } + } + return result; +} + +[[nodiscard]] std::vector ExtractLinkCandidates(const QString &input) { auto urls = std::vector(); - static auto urlRegex = QRegularExpression(R"((https?:\/\/[^\s]+))"); + static const auto urlRegex = QRegularExpression( + R"((?:https?:\/\/[^\s]+|tg:\/\/[^\s]+|(?:www\.)?(?:t\.me|telegram\.me|telegram\.dog)\/[^\s]+))", + QRegularExpression::CaseInsensitiveOption); auto it = urlRegex.globalMatch(input); while (it.hasNext()) { - urls.push_back(it.next().captured(1)); + urls.push_back(it.next().captured(0)); } return urls; } -[[nodiscard]] QString ProxyDataToString(const ProxyData &proxy) { +[[nodiscard]] bool ProxyDataIsShareable(const ProxyData &proxy) { using Type = ProxyData::Type; - return u"tg://"_q - + (proxy.type == Type::Socks5 ? "socks" : "proxy") + return (proxy.type == Type::Socks5) + || (proxy.type == Type::Mtproto); +} + +[[nodiscard]] QString ProxyDataToQueryPath(const ProxyData &proxy) { + using Type = ProxyData::Type; + const auto path = [&] { + switch (proxy.type) { + case Type::Socks5: return u"socks"_q; + case Type::Mtproto: return u"proxy"_q; + case Type::None: + case Type::Http: return QString(); + } + Unexpected("Proxy type in ProxyDataToQueryPath."); + }(); + if (path.isEmpty()) { + return QString(); + } + return path + "?server=" + proxy.host + "&port=" + QString::number(proxy.port) + ((proxy.type == Type::Socks5 && !proxy.user.isEmpty()) ? "&user=" + qthelp::url_encode(proxy.user) : "") @@ -87,6 +125,148 @@ using ProxyData = MTP::ProxyData; ? "&secret=" + proxy.password : ""); } +[[nodiscard]] QString ProxyDataToLocalLink(const ProxyData &proxy) { + const auto queryPath = ProxyDataToQueryPath(proxy); + return queryPath.isEmpty() ? QString() : (u"tg://"_q + queryPath); +} + +[[nodiscard]] QString ProxyDataToPublicLink( + not_null account, + const ProxyData &proxy) { + const auto queryPath = ProxyDataToQueryPath(proxy); + if (queryPath.isEmpty()) { + return QString(); + } + if (const auto session = account->maybeSession()) { + return session->createInternalLinkFull(queryPath); + } + auto domain = MTP::ConfigFields( + account->mtp().environment() + ).internalLinksDomain; + if (domain.endsWith('/') && queryPath.startsWith('/')) { + domain.chop(1); + } else if (!domain.endsWith('/') && !queryPath.startsWith('/')) { + domain += '/'; + } + return domain + queryPath; +} + +[[nodiscard]] QColor ProxyQrActiveColor() { + return QColor(0x40, 0xA7, 0xE3); +} + +[[nodiscard]] QImage ProxyQr(const Qr::Data &data, int pixel, int max = 0) { + Expects(data.size > 0); + + const auto available = max + ? std::max(max - 2 * st::introQrBackgroundSkip, 1) + : 0; + if (available > 0 && data.size * pixel > available) { + pixel = std::max(available / data.size, 1); + } + return Qr::Generate( + data, + pixel * style::DevicePixelRatio(), + Qt::black, + Qt::white); +} + +[[nodiscard]] QImage ProxyQrLogo() { + const auto size = QSize(st::introQrCenterSize, st::introQrCenterSize); + auto result = QImage( + size * style::DevicePixelRatio(), + QImage::Format_ARGB32_Premultiplied); + result.fill(Qt::transparent); + result.setDevicePixelRatio(style::DevicePixelRatio()); + { + auto p = QPainter(&result); + auto hq = PainterHighQualityEnabler(p); + p.setBrush(ProxyQrActiveColor()); + p.setPen(Qt::NoPen); + p.drawEllipse(QRect(QPoint(), size)); + st::introQrPlane.paintInCenter(p, QRect(QPoint(), size)); + } + return result; +} + +[[nodiscard]] QImage ProxyQrTile(const QString &link, int max = 0) { + const auto data = Qr::Encode(link, Qr::Redundancy::Quartile); + const auto qr = ProxyQr( + data, + st::introQrPixel, + max); + const auto qrSize = qr.width() / style::DevicePixelRatio(); + const auto skip = st::introQrBackgroundSkip; + const auto size = QSize(qrSize + 2 * skip, qrSize + 2 * skip); + auto result = QImage( + size * style::DevicePixelRatio(), + QImage::Format_ARGB32_Premultiplied); + result.fill(Qt::transparent); + result.setDevicePixelRatio(style::DevicePixelRatio()); + { + auto p = QPainter(&result); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + p.drawRoundedRect( + QRect(QPoint(), size), + st::introQrBackgroundRadius, + st::introQrBackgroundRadius); + p.drawImage(QRect(skip, skip, qrSize, qrSize), qr); + const auto logo = ProxyQrLogo(); + p.drawImage( + QRect( + (size.width() - st::introQrCenterSize) / 2, + (size.height() - st::introQrCenterSize) / 2, + st::introQrCenterSize, + st::introQrCenterSize), + logo); + } + return result; +} + +[[nodiscard]] QImage ProxyQrForShare(const QString &link) { + return ProxyQrTile( + link, + st::boxWidth - st::boxRowPadding.left() - st::boxRowPadding.right()); +} + +void ShowProxyQrBox(std::shared_ptr show, const QString &link) { + show->showBox(Box([=](not_null box) { + box->setTitle(tr::lng_proxy_edit_share_qr_box_title()); + box->addButton(tr::lng_about_done(), [=] { box->closeBox(); }); + + const auto copyCallback = [=, image = ProxyQrForShare(link)] { + QGuiApplication::clipboard()->setImage(image); + show->showToast(tr::lng_group_invite_qr_copied(tr::now)); + }; + + const auto qr = ProxyQrTile( + link, + st::boxWidth + - st::boxRowPadding.left() + - st::boxRowPadding.right()); + const auto size = qr.width() / style::DevicePixelRatio(); + const auto height = st::inviteLinkQrSkip * 2 + size; + const auto container = box->addRow( + object_ptr(box, height), + st::inviteLinkQrMargin); + const auto button = Ui::CreateChild(container); + button->resize(size, size); + button->paintRequest( + ) | rpl::on_next([=] { + QPainter(button).drawImage(QRect(0, 0, size, size), qr); + }, button->lifetime()); + container->widthValue( + ) | rpl::on_next([=](int width) { + button->move((width - size) / 2, st::inviteLinkQrSkip); + }, button->lifetime()); + button->setClickedCallback(copyCallback); + + box->addLeftButton(tr::lng_group_invite_context_copy(), copyCallback); + })); +} + [[nodiscard]] ProxyData ProxyDataFromFields( ProxyData::Type type, const QMap &fields) { @@ -110,7 +290,7 @@ void AddProxyFromClipboard( const auto socksString = u"socks"_q; const auto protocol = u"tg://"_q; - const auto maybeUrls = ExtractUrlsSimple( + const auto maybeUrls = ExtractLinkCandidates( QGuiApplication::clipboard()->text()); const auto isSingle = maybeUrls.size() == 1; @@ -128,8 +308,8 @@ void AddProxyFromClipboard( protocol.size(), 8192); - if (local.startsWith(protocol + proxyString) - || local.startsWith(protocol + socksString)) { + if (local.startsWith(protocol + proxyString, Qt::CaseInsensitive) + || local.startsWith(protocol + socksString, Qt::CaseInsensitive)) { using namespace qthelp; const auto options = RegExOption::CaseInsensitive; @@ -188,7 +368,10 @@ void AddProxyFromClipboard( auto success = Result::Failed; for (const auto &maybeUrl : maybeUrls) { - const auto result = proceedUrl(Core::TryConvertUrlToLocal(maybeUrl)); + const auto trimmed = maybeUrl.trimmed(); + const auto local = Core::TryConvertUrlToLocal(trimmed); + const auto check = local.isEmpty() ? trimmed : local; + const auto result = proceedUrl(check); if (success != Result::Success) { success = result; } @@ -376,12 +559,16 @@ private: void setupButtons(int id, not_null button); int rowHeight() const; void refreshProxyForCalls(); + void refreshProxyRotation(); not_null _controller; Core::SettingsProxy &_settings; QPointer _tryIPv6; std::shared_ptr> _proxySettings; QPointer> _proxyForCalls; + QPointer> _proxyRotation; + QPointer> _proxyRotationOptions; + QPointer _proxyRotationTimeout; QPointer _about; base::unique_qptr _noRows; object_ptr _initialWrap; @@ -426,6 +613,7 @@ private: not_null parent, const QString &text) const; + const bool _allowShare = false; Fn _callback; Fn _shareCallback; @@ -900,6 +1088,47 @@ void ProxiesBox::setupContent() { 0, st::proxyTryIPv6Padding.right(), st::proxyTryIPv6Padding.top())); + _proxyRotation = inner->add( + object_ptr>( + inner, + object_ptr( + inner, + tr::lng_proxy_auto_switch(tr::now), + _settings.proxyRotationEnabled()), + style::margins( + 0, + st::proxyUsePadding.top(), + 0, + st::proxyUsePadding.bottom())), + style::margins( + st::proxyTryIPv6Padding.left(), + 0, + st::proxyTryIPv6Padding.right(), + st::proxyTryIPv6Padding.top())); + _proxyRotationOptions = inner->add( + object_ptr>( + inner, + object_ptr(inner))); + _proxyRotationTimeout = _proxyRotationOptions->entity()->add( + object_ptr( + _proxyRotationOptions->entity(), + st::settingsSlider), + st::settingsBigScalePadding); + for (const auto seconds : Core::SettingsProxy::kProxyRotationTimeouts) { + _proxyRotationTimeout->addSection( + tr::lng_proxy_auto_switch_timeout( + tr::now, + lt_count, + seconds)); + } + _proxyRotationTimeout->setActiveSectionFast( + ClosestProxyRotationTimeoutSection(_settings.proxyRotationTimeout())); + _proxyRotationOptions->entity()->add( + object_ptr( + _proxyRotationOptions->entity(), + tr::lng_proxy_auto_switch_about(tr::now), + st::boxDividerLabel), + st::proxyAboutPadding); _about = inner->add( object_ptr( @@ -922,6 +1151,7 @@ void ProxiesBox::setupContent() { addNewProxy(); } refreshProxyForCalls(); + refreshProxyRotation(); }); _tryIPv6->checkedChanges( ) | rpl::on_next([=](bool checked) { @@ -931,18 +1161,33 @@ void ProxiesBox::setupContent() { _controller->proxySettingsValue( ) | rpl::on_next([=](ProxyData::Settings value) { _proxySettings->setValue(value); + refreshProxyForCalls(); + refreshProxyRotation(); }, inner->lifetime()); _proxyForCalls->entity()->checkedChanges( ) | rpl::on_next([=](bool checked) { _controller->setProxyForCalls(checked); }, _proxyForCalls->lifetime()); + _proxyRotation->entity()->checkedChanges( + ) | rpl::on_next([=](bool checked) { + _controller->setProxyRotationEnabled(checked); + refreshProxyRotation(); + }, _proxyRotation->lifetime()); + _proxyRotationTimeout->sectionActivated( + ) | rpl::on_next([=](int section) { + _controller->setProxyRotationTimeout( + Core::SettingsProxy::kProxyRotationTimeouts[section]); + }, _proxyRotationTimeout->lifetime()); if (_rows.empty()) { createNoRowsLabel(); } refreshProxyForCalls(); + refreshProxyRotation(); _proxyForCalls->finishAnimating(); + _proxyRotation->finishAnimating(); + _proxyRotationOptions->finishAnimating(); { const auto wrap = inner->add( @@ -987,6 +1232,20 @@ void ProxiesBox::refreshProxyForCalls() { anim::type::normal); } +void ProxiesBox::refreshProxyRotation() { + if (!_proxyRotation || !_proxyRotationOptions) { + return; + } + const auto visible = (_proxySettings->current() + == ProxyData::Settings::Enabled) + && _settings.selected() + && (_settings.list().size() > 1); + _proxyRotation->toggle(visible, anim::type::normal); + _proxyRotationOptions->toggle( + visible && _proxyRotation->entity()->checked(), + anim::type::normal); +} + int ProxiesBox::rowHeight() const { return st::proxyRowPadding.top() + st::semiboldFont->height @@ -1029,6 +1288,7 @@ void ProxiesBox::applyView(View &&view) { } else { i->second->updateFields(std::move(view)); } + refreshProxyRotation(); } void ProxiesBox::createNoRowsLabel() { @@ -1087,7 +1347,8 @@ ProxyBox::ProxyBox( const ProxyData &data, Fn callback, Fn shareCallback) -: _callback(std::move(callback)) +: _allowShare(data.type != Type::None) +, _callback(std::move(callback)) , _shareCallback(std::move(shareCallback)) , _content(this) { setupControls(data); @@ -1156,7 +1417,8 @@ void ProxyBox::refreshButtons() { addButton(tr::lng_cancel(), [=] { closeBox(); }); const auto type = _type->current(); - if (type == Type::Socks5 || type == Type::Mtproto) { + if (_allowShare + && (type == Type::Socks5 || type == Type::Mtproto)) { addLeftButton(tr::lng_proxy_share(), [=] { share(); }); } } @@ -1203,10 +1465,10 @@ ProxyData ProxyBox::collectData() { } void ProxyBox::setupTypes() { - const auto types = std::map{ - { Type::Http, "HTTP" }, - { Type::Socks5, "SOCKS5" }, - { Type::Mtproto, "MTPROTO" }, + const auto types = std::vector>{ + { Type::Mtproto, u"MTPROTO"_q }, + { Type::Socks5, u"SOCKS5"_q }, + { Type::Http, u"HTTP"_q }, }; for (const auto &[type, label] : types) { _content->add( @@ -1318,7 +1580,7 @@ void ProxyBox::setupMtprotoCredentials(const ProxyData &data) { void ProxyBox::setupControls(const ProxyData &data) { _type = std::make_shared>( (data.type == Type::None - ? Type::Socks5 + ? Type::Mtproto : data.type)); _content.create(this); _content->resizeToWidth(st::boxWideWidth); @@ -1359,95 +1621,7 @@ void ProxyBox::addLabel( } using Connection = MTP::details::AbstractConnection; -using Checker = MTP::details::ConnectionPointer; - -void ResetProxyCheckers(Checker &v4, Checker &v6) { - v4 = nullptr; - v6 = nullptr; -} - -void DropProxyChecker(Checker &v4, Checker &v6, not_null raw) { - if (v4.get() == raw) { - v4 = nullptr; - } else if (v6.get() == raw) { - v6 = nullptr; - } -} - -[[nodiscard]] bool HasProxyCheckers(const Checker &v4, const Checker &v6) { - return v4 || v6; -} - -void StartProxyCheck( - not_null mtproto, - const ProxyData &proxy, - Checker &v4, - Checker &v6, - Fn done, - Fn fail) { - using Variants = MTP::DcOptions::Variants; - - ResetProxyCheckers(v4, v6); - const auto connType = (proxy.type == ProxyData::Type::Http) - ? Variants::Http - : Variants::Tcp; - const auto dcId = mtproto->mainDcId(); - const auto setup = [&](Checker &checker, const bytes::vector &secret) { - checker = Connection::Create( - mtproto, - connType, - QThread::currentThread(), - secret, - proxy); - const auto raw = checker.get(); - raw->connect(raw, &Connection::connected, [=] { - if (done) { - done(raw, raw->pingTime()); - } - }); - const auto failed = [=] { - if (fail) { - fail(raw); - } - }; - raw->connect(raw, &Connection::disconnected, failed); - raw->connect(raw, &Connection::error, failed); - }; - if (proxy.type == ProxyData::Type::Mtproto) { - const auto secret = proxy.secretFromMtprotoPassword(); - setup(v4, secret); - v4->connectToServer( - proxy.host, - proxy.port, - secret, - dcId, - false); - return; - } - const auto options = mtproto->dcOptions().lookup( - dcId, - MTP::DcType::Regular, - true); - const auto tryConnect = [&](Checker &checker, Variants::Address address) { - const auto &list = options.data[address][connType]; - if (list.empty() - || ((address == Variants::IPv6) - && !Core::App().settings().proxy().tryIPv6())) { - checker = nullptr; - return; - } - const auto &endpoint = list.front(); - setup(checker, endpoint.secret); - checker->connectToServer( - QString::fromStdString(endpoint.ip), - endpoint.port, - endpoint.secret, - dcId, - false); - }; - tryConnect(v4, Variants::IPv4); - tryConnect(v6, Variants::IPv6); -} +using Checker = MTP::ProxyCheckConnection; } // namespace @@ -1609,18 +1783,19 @@ void ProxiesBoxController::ShowApplyConfirmation( }; statusLabel->setTextColorOverride(st::proxyRowStatusFg->c); relayout(); - StartProxyCheck( + MTP::StartProxyCheck( &account->mtp(), proxy, + Core::App().settings().proxy().tryIPv6(), state->v4, state->v6, [=](Connection *raw, int ping) { if (!weak || state->finished) { return; } - DropProxyChecker(state->v4, state->v6, raw); + MTP::DropProxyChecker(state->v4, state->v6, raw); state->finished = true; - ResetProxyCheckers(state->v4, state->v6); + MTP::ResetProxyCheckers(state->v4, state->v6); state->statusValue = TextWithEntities{ tr::lng_proxy_box_table_available( tr::now, @@ -1635,19 +1810,34 @@ void ProxiesBoxController::ShowApplyConfirmation( if (!weak || state->finished) { return; } - DropProxyChecker(state->v4, state->v6, raw); - if (!HasProxyCheckers(state->v4, state->v6)) { + MTP::DropProxyChecker(state->v4, state->v6, raw); + if (!MTP::HasProxyCheckers(state->v4, state->v6)) { state->finished = true; setUnavailable(); } }); - if (!HasProxyCheckers(state->v4, state->v6)) { + if (!MTP::HasProxyCheckers(state->v4, state->v6)) { state->finished = true; setUnavailable(); } }; statusLabel->setClickHandlerFilter([=](const auto &...) { - runCheck(); + auto &proxy = Core::App().settings().proxy(); + if (proxy.checkIpWarningShown()) { + runCheck(); + } else { + box->uiShow()->showBox(Ui::MakeConfirmBox({ + .text = tr::lng_proxy_check_ip_warning(), + .confirmed = [=] { + auto &proxy = Core::App().settings().proxy(); + proxy.setCheckIpWarningShown(true); + Local::writeSettings(); + runCheck(); + }, + .confirmText = tr::lng_proxy_check_ip_proceed(), + .title = tr::lng_proxy_check_ip_warning_title(), + })); + } return false; }); } @@ -1666,9 +1856,9 @@ void ProxiesBoxController::ShowApplyConfirmation( const auto enableButton = box->addButton( tr::lng_proxy_box_table_button(), [=] { - auto &proxies = Core::App().settings().proxy().list(); - if (!ranges::contains(proxies, proxy)) { - proxies.push_back(proxy); + auto &settings = Core::App().settings().proxy(); + if (settings.indexInList(proxy) < 0) { + settings.addToList(proxy); } Core::App().setCurrentProxy( proxy, @@ -1704,9 +1894,10 @@ auto ProxiesBoxController::proxySettingsValue() const void ProxiesBoxController::refreshChecker(Item &item) { item.state = ItemState::Checking; const auto id = item.id; - StartProxyCheck( + MTP::StartProxyCheck( &_account->mtp(), item.data, + Core::App().settings().proxy().tryIPv6(), item.checker, item.checkerv6, [=](Connection *raw, int pingTime) { @@ -1717,8 +1908,8 @@ void ProxiesBoxController::refreshChecker(Item &item) { if (item == end(_list)) { return; } - DropProxyChecker(item->checker, item->checkerv6, raw); - ResetProxyCheckers(item->checker, item->checkerv6); + MTP::DropProxyChecker(item->checker, item->checkerv6, raw); + MTP::ResetProxyCheckers(item->checker, item->checkerv6); if (item->state == ItemState::Checking) { item->state = ItemState::Available; item->ping = pingTime; @@ -1733,14 +1924,14 @@ void ProxiesBoxController::refreshChecker(Item &item) { if (item == end(_list)) { return; } - DropProxyChecker(item->checker, item->checkerv6, raw); - if (!HasProxyCheckers(item->checker, item->checkerv6) + MTP::DropProxyChecker(item->checker, item->checkerv6, raw); + if (!MTP::HasProxyCheckers(item->checker, item->checkerv6) && item->state == ItemState::Checking) { item->state = ItemState::Unavailable; updateView(*item); } }); - if (!HasProxyCheckers(item.checker, item.checkerv6)) { + if (!MTP::HasProxyCheckers(item.checker, item.checkerv6)) { item.state = ItemState::Unavailable; } } @@ -1802,8 +1993,11 @@ void ProxiesBoxController::shareItem(int id, bool qr) { void ProxiesBoxController::shareItems() { auto result = QString(); for (const auto &item : _list) { - if (!item.deleted) { - result += ProxyDataToString(item.data) + '\n' + '\n'; + if (!item.deleted && ProxyDataIsShareable(item.data)) { + const auto link = ProxyDataToPublicLink(_account, item.data); + if (!link.isEmpty()) { + result += (result.isEmpty() ? QString() : u"\n\n"_q) + link; + } } } if (result.isEmpty()) { @@ -1839,8 +2033,8 @@ void ProxiesBoxController::setDeleted(int id, bool deleted) { item->deleted = deleted; if (deleted) { - auto &proxies = _settings.list(); - proxies.erase(ranges::remove(proxies, item->data), end(proxies)); + const auto removed = _settings.removeFromList(item->data); + Assert(removed); if (item->data == _settings.selected()) { _lastSelectedProxy = _settings.selected(); @@ -1856,16 +2050,19 @@ void ProxiesBoxController::setDeleted(int id, bool deleted) { } } } else { - auto &proxies = _settings.list(); - if (ranges::find(proxies, item->data) == end(proxies)) { + if (_settings.indexInList(item->data) < 0) { + const auto &proxies = _settings.list(); auto insertBefore = item + 1; while (insertBefore != end(_list) && insertBefore->deleted) { ++insertBefore; } - auto insertBeforeIt = (insertBefore == end(_list)) - ? end(proxies) - : ranges::find(proxies, insertBefore->data); - proxies.insert(insertBeforeIt, item->data); + const auto foundIndex = (insertBefore == end(_list)) + ? int(proxies.size()) + : _settings.indexInList(insertBefore->data); + const auto insertIndex = (foundIndex >= 0) + ? foundIndex + : int(proxies.size()); + _settings.insertToList(insertIndex, item->data); } if (!_settings.selected() && _lastSelectedProxy == item->data) { @@ -1904,8 +2101,8 @@ object_ptr ProxiesBoxController::editItemBox(int id) { void ProxiesBoxController::replaceItemWith( std::vector::iterator which, std::vector::iterator with) { - auto &proxies = _settings.list(); - proxies.erase(ranges::remove(proxies, which->data), end(proxies)); + const auto removed = _settings.removeFromList(which->data); + Assert(removed); _views.fire({ which->id }); _list.erase(which); @@ -1924,10 +2121,8 @@ void ProxiesBoxController::replaceItemValue( restoreItem(which->id); } - auto &proxies = _settings.list(); - const auto i = ranges::find(proxies, which->data); - Assert(i != end(proxies)); - *i = proxy; + const auto replaced = _settings.replaceInList(which->data, proxy); + Assert(replaced); which->data = proxy; refreshChecker(*which); @@ -1963,8 +2158,7 @@ bool ProxiesBoxController::contains(const ProxyData &proxy) const { } void ProxiesBoxController::addNewItem(const ProxyData &proxy) { - auto &proxies = _settings.list(); - proxies.push_back(proxy); + _settings.addToList(proxy); _list.push_back({ ++_idCounter, proxy }); refreshChecker(_list.back()); @@ -2001,6 +2195,22 @@ void ProxiesBoxController::setProxyForCalls(bool enabled) { saveDelayed(); } +void ProxiesBoxController::setProxyRotationEnabled(bool enabled) { + if (_settings.proxyRotationEnabled() == enabled) { + return; + } + _settings.setProxyRotationEnabled(enabled); + saveDelayed(); +} + +void ProxiesBoxController::setProxyRotationTimeout(int value) { + if (_settings.proxyRotationTimeout() == value) { + return; + } + _settings.setProxyRotationTimeout(value); + saveDelayed(); +} + void ProxiesBoxController::setTryIPv6(bool enabled) { if (Core::App().settings().proxy().tryIPv6() == enabled) { return; @@ -2012,6 +2222,7 @@ void ProxiesBoxController::setTryIPv6(bool enabled) { } void ProxiesBoxController::saveDelayed() { + Core::App().proxyRotationSettingsChanged(); _saveTimer.callOnce(kSaveSettingsDelayedTimeout); } @@ -2022,7 +2233,7 @@ auto ProxiesBoxController::views() const -> rpl::producer { rpl::producer ProxiesBoxController::listShareableChanges() const { return _views.events_starting_with(ItemView()) | rpl::map([=] { for (const auto &item : _list) { - if (!item.deleted) { + if (!item.deleted && ProxyDataIsShareable(item.data)) { return true; } } @@ -2049,8 +2260,7 @@ void ProxiesBoxController::updateView(const Item &item) { } return ItemState::Connecting; }(); - const auto supportsShare = (item.data.type == Type::Socks5) - || (item.data.type == Type::Mtproto); + const auto supportsShare = ProxyDataIsShareable(item.data); const auto supportsCalls = item.data.supportsCalls(); _views.fire({ item.id, @@ -2067,18 +2277,29 @@ void ProxiesBoxController::updateView(const Item &item) { } void ProxiesBoxController::share(const ProxyData &proxy, bool qr) { - if (proxy.type == Type::Http) { + if (!ProxyDataIsShareable(proxy)) { + return; + } + const auto qrLink = ProxyDataToLocalLink(proxy); + if (qrLink.isEmpty()) { return; } - const auto link = ProxyDataToString(proxy); if (qr) { - _show->showBox(Box([=](not_null box) { - Ui::FillPeerQrBox(box, nullptr, link, rpl::single(QString())); - box->setTitle(tr::lng_proxy_edit_share_qr_box_title()); - })); + if (_account->sessionExists()) { + _show->showBox(Box([=](not_null box) { + Ui::FillPeerQrBox(box, nullptr, qrLink, rpl::single(QString())); + box->setTitle(tr::lng_proxy_edit_share_qr_box_title()); + })); + } else { + ShowProxyQrBox(_show, qrLink); + } return; } - QGuiApplication::clipboard()->setText(link); + const auto shareLink = ProxyDataToPublicLink(_account, proxy); + if (shareLink.isEmpty()) { + return; + } + QGuiApplication::clipboard()->setText(shareLink); _show->showToast(tr::lng_username_copied(tr::now)); } diff --git a/Telegram/SourceFiles/boxes/connection_box.h b/Telegram/SourceFiles/boxes/connection_box.h index c33f8ea0f4..5de98bc663 100644 --- a/Telegram/SourceFiles/boxes/connection_box.h +++ b/Telegram/SourceFiles/boxes/connection_box.h @@ -85,6 +85,8 @@ public: object_ptr addNewItemBox(); bool setProxySettings(ProxyData::Settings value); void setProxyForCalls(bool enabled); + void setProxyRotationEnabled(bool enabled); + void setProxyRotationTimeout(int value); void setTryIPv6(bool enabled); rpl::producer proxySettingsValue() const; diff --git a/Telegram/SourceFiles/boxes/create_poll_box.cpp b/Telegram/SourceFiles/boxes/create_poll_box.cpp index de621d2be1..bbd253a4d2 100644 --- a/Telegram/SourceFiles/boxes/create_poll_box.cpp +++ b/Telegram/SourceFiles/boxes/create_poll_box.cpp @@ -1049,7 +1049,7 @@ void Options::removeDestroyed(not_null option) { void Options::validateState() { checkLastOption(); - _hasOptions = (ranges::count_if(_list, &Option::isGood) > 1); + _hasOptions = (ranges::count_if(_list, &Option::isGood) > 0); _isValid = _hasOptions && ranges::none_of(_list, &Option::isTooLong); _hasCorrect = ranges::any_of(_list, &Option::isCorrect); diff --git a/Telegram/SourceFiles/boxes/dictionaries_manager.cpp b/Telegram/SourceFiles/boxes/dictionaries_manager.cpp index 13fddb7e2b..2ab2889ab7 100644 --- a/Telegram/SourceFiles/boxes/dictionaries_manager.cpp +++ b/Telegram/SourceFiles/boxes/dictionaries_manager.cpp @@ -211,6 +211,7 @@ auto AddButtonWithLoader( buttonState->value() | rpl::map(StateDescription), st::settingsUpdateState); label->setAttribute(Qt::WA_TransparentForMouseEvents); + label->show(); rpl::combine( button->widthValue(), @@ -344,24 +345,44 @@ void Inner::setupContent( const auto queryStream = content->lifetime() .make_state>(); - for (const auto &dict : Spellchecker::Dictionaries()) { - const auto id = dict.id; - const auto row = AddButtonWithLoader( - content, - session, - dict, - ranges::contains(enabledDictionaries, id), - queryStream->events()); - row->toggledValue( - ) | rpl::on_next([=](auto enabled) { - if (enabled) { - _enabledRows.push_back(id); - } else { - auto &rows = _enabledRows; - rows.erase(ranges::remove(rows, id), end(rows)); - } - }, row->lifetime()); - } + // Rows are created once, when Spellchecker::Dictionaries() becomes + // non-empty. Manifest is fetched lazily and may arrive after the box + // opens, so we subscribe to DictionariesChanged and populate rows + // then if we haven't already. + const auto built = content->lifetime().make_state(false); + const auto buildRows = [=] { + if (*built) { + return; + } + const auto dicts = Spellchecker::Dictionaries(); + if (dicts.empty()) { + return; + } + *built = true; + for (const auto &dict : dicts) { + const auto id = dict.id; + const auto row = AddButtonWithLoader( + content, + session, + dict, + ranges::contains(enabledDictionaries, id), + queryStream->events()); + row->toggledValue( + ) | rpl::on_next([=](auto enabled) { + if (enabled) { + _enabledRows.push_back(id); + } else { + auto &rows = _enabledRows; + rows.erase(ranges::remove(rows, id), end(rows)); + } + }, row->lifetime()); + } + }; + + buildRows(); + Spellchecker::DictionariesChanged( + ) | rpl::on_next(buildRows, content->lifetime()); + Spellchecker::RefreshDictionariesManifest(session); _queryCallback = [=](const QString &query) { if (query.size() >= kMaxQueryLength) { @@ -403,11 +424,6 @@ void ManageDictionariesBox::prepare() { multiSelect->setInnerFocus(); }; - // The initial list of enabled rows may differ from the list of languages - // in settings, so we should store it when box opens - // and save it when box closes (don't do it when "Save" was pressed). - const auto initialEnabledRows = inner->enabledRows(); - setTitle(tr::lng_settings_manage_dictionaries()); addButton(tr::lng_settings_save(), [=] { @@ -421,8 +437,12 @@ void ManageDictionariesBox::prepare() { addButton(tr::lng_close(), [=] { closeBox(); }); boxClosing() | rpl::on_next([=] { - Core::App().settings().setDictionariesEnabled( - FilterEnabledDict(initialEnabledRows)); + const auto ¤t = Core::App().settings().dictionariesEnabled(); + const auto filtered = FilterEnabledDict(current); + if (filtered.size() == current.size()) { + return; + } + Core::App().settings().setDictionariesEnabled(filtered); Core::App().saveSettingsDelayed(); }, lifetime()); diff --git a/Telegram/SourceFiles/boxes/edit_todo_list_box.cpp b/Telegram/SourceFiles/boxes/edit_todo_list_box.cpp index 96712d2293..c47cf00040 100644 --- a/Telegram/SourceFiles/boxes/edit_todo_list_box.cpp +++ b/Telegram/SourceFiles/boxes/edit_todo_list_box.cpp @@ -53,7 +53,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace { -constexpr auto kMaxOptionsCount = TodoListData::kMaxOptions; constexpr auto kWarnTitleLimit = 12; constexpr auto kWarnTaskLimit = 24; constexpr auto kErrorLimit = 99; diff --git a/Telegram/SourceFiles/boxes/language_box.cpp b/Telegram/SourceFiles/boxes/language_box.cpp index 76813ddf15..bef9ed54a9 100644 --- a/Telegram/SourceFiles/boxes/language_box.cpp +++ b/Telegram/SourceFiles/boxes/language_box.cpp @@ -7,51 +7,55 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "boxes/language_box.h" -#include "data/data_peer_values.h" -#include "lang/lang_keys.h" -#include "ui/boxes/choose_language_box.h" -#include "ui/widgets/checkbox.h" -#include "ui/widgets/buttons.h" -#include "ui/widgets/labels.h" -#include "ui/widgets/multi_select.h" -#include "ui/widgets/scroll_area.h" -#include "ui/widgets/dropdown_menu.h" -#include "ui/widgets/box_content_divider.h" -#include "ui/text/text_entity.h" -#include "ui/wrap/vertical_layout.h" -#include "ui/wrap/slide_wrap.h" -#include "ui/effects/ripple_animation.h" -#include "ui/toast/toast.h" -#include "ui/text/text_options.h" -#include "ui/painter.h" -#include "ui/vertical_list.h" -#include "ui/ui_utility.h" -#include "storage/localstorage.h" +#include "base/platform/base_platform_info.h" #include "boxes/abstract_box.h" #include "boxes/premium_preview_box.h" #include "boxes/translate_box.h" -#include "ui/boxes/confirm_box.h" -#include "main/main_session.h" -#include "mainwidget.h" -#include "mainwindow.h" #include "core/application.h" -#include "base/platform/base_platform_info.h" -#include "lang/lang_instance.h" +#include "data/data_peer_values.h" #include "lang/lang_cloud_manager.h" +#include "lang/lang_instance.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "platform/platform_translate_provider.h" #include "settings/settings_common.h" #include "spellcheck/spellcheck_types.h" +#include "storage/localstorage.h" +#include "ui/accessible/ui_accessible_item.h" +#include "ui/boxes/choose_language_box.h" +#include "ui/boxes/confirm_box.h" +#include "ui/effects/ripple_animation.h" +#include "ui/text/text_entity.h" +#include "ui/text/text_options.h" +#include "ui/toast/toast.h" +#include "ui/widgets/box_content_divider.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/checkbox.h" +#include "ui/widgets/dropdown_menu.h" +#include "ui/widgets/labels.h" +#include "ui/widgets/multi_select.h" +#include "ui/widgets/scroll_area.h" +#include "ui/wrap/slide_wrap.h" +#include "ui/wrap/vertical_layout.h" +#include "ui/painter.h" +#include "ui/screen_reader_mode.h" +#include "ui/ui_utility.h" +#include "ui/vertical_list.h" #include "window/window_controller.h" #include "window/window_session_controller.h" -#include "styles/style_layers.h" +#include "mainwidget.h" +#include "mainwindow.h" + #include "styles/style_boxes.h" -#include "styles/style_info.h" -#include "styles/style_passport.h" #include "styles/style_chat_helpers.h" +#include "styles/style_info.h" +#include "styles/style_layers.h" #include "styles/style_menu_icons.h" +#include "styles/style_passport.h" #include "styles/style_settings.h" -#include #include +#include namespace { @@ -70,11 +74,13 @@ public: int count() const; int selected() const; + int chosenIndex() const; void setSelected(int selected); rpl::producer hasSelection() const; rpl::producer isEmpty() const; void activateSelected(); + void selectSkip(int dir); rpl::producer activations() const; void changeChosen(const QString &chosen); @@ -82,10 +88,24 @@ public: static int DefaultRowHeight(); + QAccessible::Role accessibilityRole() override; + Qt::FocusPolicy accessibilityFocusPolicy() override; + QAccessible::Role accessibilityChildRole() const override; + QAccessible::State accessibilityChildState(int index) const override; + int accessibilityChildCount() const override; + QString accessibilityChildName(int index) const override; + QRect accessibilityChildRect(int index) const override; + int accessibilityChildColumnCount(int row) const override; + QAccessible::Role accessibilityChildSubItemRole() const override; + QString accessibilityChildSubItemName(int row, int column) const override; + QString accessibilityChildSubItemValue(int row, int column) const override; + protected: int resizeGetHeight(int newWidth) override; + void focusInEvent(QFocusEvent *e) override; void paintEvent(QPaintEvent *e) override; + void keyPressEvent(QKeyEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; void mousePressEvent(QMouseEvent *e) override; void mouseReleaseEvent(QMouseEvent *e) override; @@ -153,6 +173,13 @@ private: void repaintChecked(not_null row); void activateByIndex(int index); + enum class Announce { + No, + OnChange, + Always, + }; + void setSelected(int index, Announce announce); + void showMenu(int index); void setForceRippled(not_null row, bool rippled); bool canShare(not_null row) const; @@ -182,6 +209,26 @@ private: }; +[[nodiscard]] bool ForwardListNavigation( + not_null e, + not_null rows, + int pageHeight) { + const auto key = e->key(); + if (key == Qt::Key_Down) { + rows->selectSkip(1); + } else if (key == Qt::Key_Up) { + rows->selectSkip(-1); + } else if (key == Qt::Key_PageDown || key == Qt::Key_PageUp) { + const auto perPage = std::max( + pageHeight / Rows::DefaultRowHeight(), + 1); + rows->selectSkip((key == Qt::Key_PageDown) ? perPage : -perPage); + } else { + return false; + } + return true; +} + class Content : public Ui::RpWidget { public: Content( @@ -287,6 +334,44 @@ Rows::Rows( resizeToWidth(width()); setAttribute(Qt::WA_MouseTracking); update(); + + setAccessibleName(tr::lng_languages(tr::now)); +} + +void Rows::focusInEvent(QFocusEvent *e) { + if (selected() < 0 && count() > 0) { + const auto chosen = chosenIndex(); + setSelected(chosen >= 0 ? chosen : 0, Announce::No); + } + RpWidget::focusInEvent(e); + const auto index = selected(); + if (index >= 0) { + InvokeQueued(this, [=] { + if (selected() == index && hasFocus()) { + accessibilityChildFocused(index); + } + }); + } +} + +void Rows::keyPressEvent(QKeyEvent *e) { + const auto pageHeight = window() ? window()->height() : height(); + if (ForwardListNavigation(e, this, pageHeight)) { + return; + } + const auto key = e->key(); + if (key == Qt::Key_Home && count() > 0) { + setSelected(0, Announce::Always); + } else if (key == Qt::Key_End && count() > 0) { + setSelected(count() - 1, Announce::Always); + } else if (!e->isAutoRepeat() + && (key == Qt::Key_Space + || key == Qt::Key_Return + || key == Qt::Key_Enter)) { + activateSelected(); + } else { + RpWidget::keyPressEvent(e); + } } void Rows::mouseMoveEvent(QMouseEvent *e) { @@ -554,7 +639,10 @@ void Rows::setForceRippled(not_null row, bool rippled) { } void Rows::activateByIndex(int index) { + _chosen = rowByIndex(index).data.id; _activations.fire_copy(rowByIndex(index).data); + accessibilityChildStateChanged(index, { .checked = true }); + accessibilityChildNameChanged(index); } void Rows::leaveEventHook(QEvent *e) { @@ -630,6 +718,15 @@ int Rows::selected() const { return indexFromSelection(_selected); } +int Rows::chosenIndex() const { + for (auto i = 0, n = count(); i < n; ++i) { + if (rowByIndex(i).data.id == _chosen) { + return i; + } + } + return -1; +} + void Rows::activateSelected() { const auto index = selected(); if (index >= 0) { @@ -637,24 +734,66 @@ void Rows::activateSelected() { } } +void Rows::selectSkip(int dir) { + const auto limit = count(); + auto now = selected(); + if (now < 0) { + now = chosenIndex(); + } + if (now >= 0) { + const auto changed = now + dir; + if (changed < 0) { + setSelected(0, Announce::Always); + } else if (changed >= limit) { + setSelected(limit - 1, Announce::Always); + } else { + setSelected(changed, Announce::Always); + } + } else if (dir > 0) { + setSelected(0, Announce::Always); + } +} + rpl::producer Rows::activations() const { return _activations.events(); } void Rows::changeChosen(const QString &chosen) { + const auto oldIndex = chosenIndex(); + _chosen = chosen; for (const auto &row : _rows) { row.check->setChecked(row.data.id == chosen, anim::type::normal); } + const auto newIndex = chosenIndex(); + if (newIndex != oldIndex && newIndex >= 0) { + accessibilityChildStateChanged(newIndex, { .checked = true }); + accessibilityChildNameChanged(newIndex); + } } void Rows::setSelected(int selected) { + setSelected(selected, Announce::OnChange); +} + +void Rows::setSelected(int selected, Announce announce) { _mouseSelection = false; const auto limit = count(); - if (selected >= 0 && selected < limit) { - updateSelected(RowSelection{ selected }); + const auto clamped = (selected >= 0 && selected < limit) + ? selected + : -1; + const auto changed = (indexFromSelection(_selected) != clamped) + || (clamped < 0 && !v::is_null(_selected)); + if (clamped >= 0) { + updateSelected(RowSelection{ clamped }); } else { updateSelected({}); } + const auto shouldAnnounce = (announce == Announce::Always) + || (announce == Announce::OnChange && changed); + if (shouldAnnounce && clamped >= 0) { + accessibilityChildNameChanged(clamped); + accessibilityChildFocused(clamped); + } } rpl::producer Rows::hasSelection() const { @@ -874,6 +1013,84 @@ void Rows::paintEvent(QPaintEvent *e) { } } +QAccessible::Role Rows::accessibilityRole() { + return QAccessible::List; +} + +Qt::FocusPolicy Rows::accessibilityFocusPolicy() { + return Qt::TabFocus; +} + +QAccessible::Role Rows::accessibilityChildRole() const { + return QAccessible::RadioButton; +} + +QAccessible::State Rows::accessibilityChildState(int index) const { + QAccessible::State state; + if (Ui::ScreenReaderModeActive()) { + state.focusable = true; + } + state.checkable = true; + state.checked = (index == chosenIndex()); + if (index == selected()) { + state.active = true; + if (hasFocus()) { + state.focused = true; + } + } + return state; +} + +int Rows::accessibilityChildCount() const { + return count(); +} + +QString Rows::accessibilityChildName(int index) const { + if (index < 0 || index >= count()) { + return {}; + } + const auto &row = rowByIndex(index); + return row.data.nativeName + u", "_q + row.data.name; +} + +QRect Rows::accessibilityChildRect(int index) const { + if (index < 0 || index >= count()) { + return {}; + } + const auto &row = rowByIndex(index); + return QRect(0, row.top, width(), row.height); +} + +int Rows::accessibilityChildColumnCount(int row) const { + return 2; +} + +QAccessible::Role Rows::accessibilityChildSubItemRole() const { + return QAccessible::Cell; +} + +QString Rows::accessibilityChildSubItemName(int row, int column) const { + if (column == 0) { + return tr::lng_sr_languages_column_native(tr::now); + } else if (column == 1) { + return tr::lng_sr_languages_column_name(tr::now); + } + return {}; +} + +QString Rows::accessibilityChildSubItemValue(int row, int column) const { + if (row < 0 || row >= count()) { + return {}; + } + const auto &data = rowByIndex(row).data; + if (column == 0) { + return data.nativeName; + } else if (column == 1) { + return data.name; + } + return {}; +} + Content::Content( QWidget *parent, const Languages &recent, diff --git a/Telegram/SourceFiles/boxes/peers/edit_peer_history_visibility_box.cpp b/Telegram/SourceFiles/boxes/peers/edit_peer_history_visibility_box.cpp index c23347df3e..ac39eef76b 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_peer_history_visibility_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_peer_history_visibility_box.cpp @@ -7,7 +7,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "boxes/peers/edit_peer_history_visibility_box.h" +#include "base/event_filter.h" #include "lang/lang_keys.h" +#include "ui/boxes/confirm_box.h" #include "ui/layers/generic_box.h" #include "ui/widgets/buttons.h" #include "ui/widgets/checkbox.h" @@ -35,12 +37,39 @@ void EditPeerHistoryVisibilityBox( button->setClickedCallback([=] { historyVisibility->setValue(v); }); }; + const auto tryClose = [=] { + if (historyVisibility->current() == historyVisibilitySavedValue) { + box->closeBox(); + return; + } + box->uiShow()->showBox(Ui::MakeConfirmBox({ + .text = tr::lng_bot_close_warning(), + .confirmed = crl::guard(box, [=](Fn close) { + close(); + box->closeBox(); + }), + .confirmText = tr::lng_bot_close_warning_sure(), + .cancelText = tr::lng_create_group_back(), + })); + }; + box->setTitle(tr::lng_manage_history_visibility_title()); box->addButton(tr::lng_settings_save(), [=] { savedCallback(historyVisibility->current()); box->closeBox(); }); - box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); + box->addButton(tr::lng_cancel(), tryClose); + + box->setCloseByOutsideClick(false); + box->setCloseByEscape(false); + base::install_event_filter(box, [=](not_null e) { + if (e->type() == QEvent::KeyPress + && static_cast(e.get())->key() == Qt::Key_Escape) { + tryClose(); + return base::EventFilterResult::Cancel; + } + return base::EventFilterResult::Continue; + }); box->addSkip(st::editPeerHistoryVisibilityTopSkip); const auto visible = box->addRow(object_ptr(box)); diff --git a/Telegram/SourceFiles/boxes/peers/peer_short_info_box.cpp b/Telegram/SourceFiles/boxes/peers/peer_short_info_box.cpp index 36b7563257..d42e478bab 100644 --- a/Telegram/SourceFiles/boxes/peers/peer_short_info_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/peer_short_info_box.cpp @@ -809,7 +809,7 @@ void PeerShortInfoBox::prepareRows() { addInfoLine(std::move(label), aboutValue(), _st.labeled); addInfoOneLine( tr::lng_info_username_label(), - usernameValue() | rpl::map(tr::marked), + usernameValue(), tr::lng_context_copy_mention(tr::now)); addInfoOneLine( birthdayLabel(), @@ -899,10 +899,12 @@ rpl::producer PeerShortInfoBox::phoneValue() const { }) | rpl::distinct_until_changed(); } -rpl::producer PeerShortInfoBox::usernameValue() const { +rpl::producer PeerShortInfoBox::usernameValue() const { return _fields.value( ) | rpl::map([](const PeerShortInfoFields &fields) { - return fields.username; + return fields.usernameLink.isEmpty() + ? TextWithEntities{ fields.username } + : tr::link(fields.username, fields.usernameLink); }) | rpl::distinct_until_changed(); } diff --git a/Telegram/SourceFiles/boxes/peers/peer_short_info_box.h b/Telegram/SourceFiles/boxes/peers/peer_short_info_box.h index 02939a5947..16fa600b93 100644 --- a/Telegram/SourceFiles/boxes/peers/peer_short_info_box.h +++ b/Telegram/SourceFiles/boxes/peers/peer_short_info_box.h @@ -47,6 +47,7 @@ struct PeerShortInfoFields { QString link; TextWithEntities about; QString username; + QString usernameLink; Data::Birthday birthday; TextWithEntities note; bool isBio = false; @@ -185,7 +186,7 @@ private: [[nodiscard]] rpl::producer channelValue() const; [[nodiscard]] rpl::producer linkValue() const; [[nodiscard]] rpl::producer phoneValue() const; - [[nodiscard]] rpl::producer usernameValue() const; + [[nodiscard]] rpl::producer usernameValue() const; [[nodiscard]] rpl::producer birthdayLabel() const; [[nodiscard]] rpl::producer birthdayValue() const; [[nodiscard]] rpl::producer aboutValue() const; diff --git a/Telegram/SourceFiles/boxes/peers/prepare_short_info_box.cpp b/Telegram/SourceFiles/boxes/peers/prepare_short_info_box.cpp index 91a05d3f0a..e2d4aa8a76 100644 --- a/Telegram/SourceFiles/boxes/peers/prepare_short_info_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/prepare_short_info_box.cpp @@ -237,6 +237,9 @@ void ProcessFullPhoto( .username = ((user && !username.isEmpty()) ? ('@' + username) : QString()), + .usernameLink = ((user && !username.isEmpty()) + ? Info::Profile::UsernameUrl(peer, username, true) + : QString()), .birthday = user ? user->birthday() : Data::Birthday(), .note = user ? user->note() : TextWithEntities(), .isBio = (user && !user->isBot()), diff --git a/Telegram/SourceFiles/boxes/polls.style b/Telegram/SourceFiles/boxes/polls.style index 6651146713..bcdafbe095 100644 --- a/Telegram/SourceFiles/boxes/polls.style +++ b/Telegram/SourceFiles/boxes/polls.style @@ -48,6 +48,8 @@ historyPollRadio: Radio(defaultRadio) { rippleAreaPadding: 8px; } historyPollCheckboxRadius: 3px; +historyPollReplyIcon: icon {{ "dialogs/dialogs_chatlist_poll-17x17", windowFg }}; +historyPollReplyIconSkip: 3px; historyPollRadioOpacity: 0.7; historyPollRadioOpacityOver: 1.; historyPollDuration: 300; diff --git a/Telegram/SourceFiles/boxes/reactions_settings_box.cpp b/Telegram/SourceFiles/boxes/reactions_settings_box.cpp index 75da06465b..873a6f16e9 100644 --- a/Telegram/SourceFiles/boxes/reactions_settings_box.cpp +++ b/Telegram/SourceFiles/boxes/reactions_settings_box.cpp @@ -21,6 +21,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/history_view_fake_items.h" #include "lang/lang_keys.h" #include "boxes/premium_preview_box.h" +#include "chat_helpers/emoji_list_widget.h" +#include "chat_helpers/tabbed_selector.h" #include "main/main_session.h" #include "settings/sections/settings_premium.h" #include "ui/chat/chat_style.h" @@ -39,6 +41,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "window/window_session_controller.h" #include "styles/style_boxes.h" #include "styles/style_chat.h" +#include "styles/style_chat_helpers.h" #include "styles/style_layers.h" #include "styles/style_media_player.h" // mediaPlayerMenuCheck #include "styles/style_settings.h" @@ -454,6 +457,56 @@ void ReactionsSettingsBox( const auto container = box->verticalLayout(); + box->setTitle(tr::lng_settings_chat_reactions_title()); + box->setWidth(st::boxWideWidth); + box->addButton(tr::lng_settings_save(), [=] { + const auto &data = controller->session().data(); + const auto selectedId = state->selectedId.current(); + if (data.reactions().favoriteId() != selectedId) { + data.reactions().setFavorite(selectedId); + } + box->closeBox(); + }); + box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); + + if (controller->session().premium()) { + using Selector = ChatHelpers::TabbedSelector; + const auto selector = container->add( + object_ptr( + container, + controller->uiShow(), + Window::GifPauseReason::Layer, + Selector::Mode::FullReactions), + style::margins()); + selector->setAllowEmojiWithoutPremium(false); + selector->setRoundRadius(0); + selector->resize(st::boxWideWidth, st::emojiPanMinHeight); + + const auto docToReaction = box->lifetime().make_state< + base::flat_map>(); + auto recentIds = std::vector(); + for (const auto &r + : reactions.list(Data::Reactions::Type::Active)) { + const auto docId = r.selectAnimation->id; + recentIds.push_back(docId); + docToReaction->emplace(docId, r.id); + } + selector->provideRecentEmoji( + ChatHelpers::DocumentListToRecent(recentIds)); + + selector->customEmojiChosen( + ) | rpl::on_next([=](ChatHelpers::FileChosen data) { + const auto docId = data.document->id; + const auto it = docToReaction->find(docId); + if (it != docToReaction->end()) { + state->selectedId = it->second; + } else { + state->selectedId = Data::ReactionId{ docId }; + } + }, selector->lifetime()); + return; + } + const auto check = Ui::CreateChild(container.get()); check->resize(st::settingsReactionCornerSize); check->setAttribute(Qt::WA_TransparentForMouseEvents); @@ -528,16 +581,4 @@ void ReactionsSettingsBox( }, firstCheckedButton->lifetime()); } check->raise(); - - box->setTitle(tr::lng_settings_chat_reactions_title()); - box->setWidth(st::boxWideWidth); - box->addButton(tr::lng_settings_save(), [=] { - const auto &data = controller->session().data(); - const auto selectedId = state->selectedId.current(); - if (data.reactions().favoriteId() != selectedId) { - data.reactions().setFavorite(selectedId); - } - box->closeBox(); - }); - box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); } diff --git a/Telegram/SourceFiles/boxes/send_files_box.cpp b/Telegram/SourceFiles/boxes/send_files_box.cpp index 9195468cbb..6dfb0ccc7f 100644 --- a/Telegram/SourceFiles/boxes/send_files_box.cpp +++ b/Telegram/SourceFiles/boxes/send_files_box.cpp @@ -37,6 +37,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/premium_preview_box.h" #include "boxes/send_gif_with_caption_box.h" #include "boxes/send_credits_box.h" +#include "boxes/send_files_box_reply_header.h" #include "ui/effects/scroll_content_shadow.h" #include "ui/widgets/fields/number_input.h" #include "ui/widgets/checkbox.h" @@ -441,6 +442,10 @@ int SendFilesBox::Block::fromIndex() const { return _from; } +bool SendFilesBox::Block::isSingleFile() const { + return !_isAlbum && !_isSingleMedia; +} + int SendFilesBox::Block::tillIndex() const { return _till; } @@ -661,9 +666,58 @@ SendFilesBox::SendFilesBox(QWidget*, SendFilesBoxDescriptor &&descriptor) , _inner( _scroll->setOwnedWidget( object_ptr(_scroll.data()))) { + setReplyTo(descriptor.replyTo); enqueueNextPrepare(); } +void SendFilesBox::setReplyTo(FullReplyTo replyTo) { + if (_replyTo == replyTo) { + return; + } else if (!replyTo.messageId || !replyTo.messageId.peer) { + _replyTo = {}; + if (_replyHeader) { + _replyHeader->hideAnimated(); + } + return; + } + _replyTo = replyTo; + if (_replyHeader) { + _replyHeader = nullptr; + _replyHeaderHeight = 0; + } + _replyHeader = std::make_unique( + this, + _show, + std::move(replyTo)); + _replyHeader->setRoundedShapeBelow( + !_blocks.empty() && !_blocks.front().isSingleFile()); + _replyHeader->show(); + _replyHeader->desiredHeight( + ) | rpl::on_next([=](int height) { + if (_replyHeaderHeight.current() != height) { + _replyHeaderHeight = height; + updateBoxSize(); + updateControlsGeometry(); + } + }, _replyHeader->lifetime()); + _replyHeader->closeRequests( + ) | rpl::on_next([=] { + _replyTo = {}; + if (_replyHeader) { + _replyHeader->hideAnimated(); + } + }, _replyHeader->lifetime()); + _replyHeader->hideFinished( + ) | rpl::on_next([=] { + InvokeQueued(this, [=] { + _replyHeader = nullptr; + _replyHeaderHeight = 0; + updateBoxSize(); + updateControlsGeometry(); + }); + }, _replyHeader->lifetime()); +} + Fn SendFilesBox::prepareSendMenuDetails( const SendFilesBoxDescriptor &descriptor) { auto initial = descriptor.sendMenuDetails; @@ -780,7 +834,7 @@ void SendFilesBox::prepare() { setCloseByOutsideClick(false); boxClosing() | rpl::on_next([=] { - if (!_confirmed && _cancelledCallback) { + if (!_confirmed && !_textTaken && _cancelledCallback) { _cancelledCallback(); } auto text = _caption->getTextWithAppliedMarkdown(); @@ -1268,6 +1322,10 @@ void SendFilesBox::generatePreviewFrom(int fromBlock) { if (albumStart >= 0) { pushBlock(albumStart, _list.files.size()); } + if (_replyHeader) { + _replyHeader->setRoundedShapeBelow( + !_blocks.empty() && !_blocks.front().isSingleFile()); + } } void SendFilesBox::pushBlock(int from, int till) { @@ -1463,7 +1521,10 @@ void SendFilesBox::pushBlock(int from, int till) { entry.videoCover = nullptr; }); }; - const auto showContextMenu = [=](int fileIndex, QPoint globalPosition) { + const auto showContextMenu = [=]( + int fileIndex, + QPoint globalPosition, + bool forceToLeft = false) { if (from >= till || fileIndex < from || fileIndex >= till @@ -1473,7 +1534,10 @@ void SendFilesBox::pushBlock(int from, int till) { state->menu = base::make_unique_q( widget, _st.tabbed.menu); - state->menu->setForcedOrigin(Ui::PanelAnimation::Origin::TopRight); + if (forceToLeft) { + using Origin = Ui::PanelAnimation::Origin; + state->menu->setForcedOrigin(Origin::TopRight); + } const auto &file = _list.files[fileIndex]; state->menu->addAction(tr::lng_attach_replace(tr::now), [=] { replaceAttachment(fileIndex); @@ -1534,7 +1598,9 @@ void SendFilesBox::pushBlock(int from, int till) { &st::menuIconCaptionShow); } const auto canToggleSpoiler = !hasPrice() - && _sendWay.current().sendImagesAsPhotos(); + && _sendWay.current().sendImagesAsPhotos() + && (file.type == Ui::PreparedFile::Type::Photo + || file.type == Ui::PreparedFile::Type::Video); if (canToggleSpoiler) { const auto spoilered = file.spoiler; const auto &icons = _st.tabbed.icons; @@ -1606,7 +1672,7 @@ void SendFilesBox::pushBlock(int from, int till) { block.itemReplaceRequest( ) | rpl::on_next([=](int index) { - showContextMenu(index, QCursor::pos()); + showContextMenu(index, QCursor::pos(), true); }, widget->lifetime()); block.itemModifyRequest( @@ -2129,6 +2195,7 @@ void SendFilesBox::updateBoxSize() { if (!_caption->isHidden()) { footerHeight += st::boxPhotoCaptionSkip + _caption->height(); } + footerHeight += _replyHeaderHeight.current(); const auto pairs = std::array, 4>{ { { _groupFiles.data(), st::boxPhotoCompressedSkip }, { _sendImagesAsPhotos.data(), st::boxPhotoCompressedSkip }, @@ -2216,8 +2283,14 @@ void SendFilesBox::updateControlsGeometry() { bottom -= pair.second + pointer->heightNoMargins(); } } - _scroll->resize(width(), bottom - _titleHeight.current()); - _scroll->move(0, _titleHeight.current()); + const auto replyH = _replyHeaderHeight.current(); + const auto replyTopOverlap = std::min(st::boxPhotoCaptionSkip, replyH); + const auto replyTop = _titleHeight.current() - replyTopOverlap; + if (_replyHeader) { + _replyHeader->setGeometry(0, replyTop, width(), replyH); + } + _scroll->resize(width(), bottom - replyTop - replyH); + _scroll->move(0, replyTop + replyH); } void SendFilesBox::showFinished() { @@ -2232,10 +2305,16 @@ rpl::producer SendFilesBox::takeTextWithTagsRequests() const { return _textWithTagsRequests.events(); } -void SendFilesBox::requestToTakeTextWithTags() const { - if (!_caption->isHidden()) { - _textWithTagsRequests.fire_copy(_caption->getTextWithTags()); +void SendFilesBox::requestToTakeTextWithTags() { + if (_caption->isHidden()) { + return; } + const auto text = _caption->getTextWithTags(); + if (!_prefilledCaptionText.text.isEmpty() && text.text.isEmpty()) { + return; + } + _textTaken = true; + _textWithTagsRequests.fire_copy(text); } void SendFilesBox::setInnerFocus() { @@ -2367,7 +2446,7 @@ void SendFilesBox::send( } } - _confirmedCallback(std::move(bundle), options); + _confirmedCallback(std::move(bundle), options, _replyTo); } closeBox(); } diff --git a/Telegram/SourceFiles/boxes/send_files_box.h b/Telegram/SourceFiles/boxes/send_files_box.h index e153c550c0..9b04cd9a37 100644 --- a/Telegram/SourceFiles/boxes/send_files_box.h +++ b/Telegram/SourceFiles/boxes/send_files_box.h @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "base/flags.h" +#include "data/data_msg_id.h" #include "ui/layers/box_content.h" #include "ui/chat/attach/attach_prepare.h" #include "ui/chat/attach/attach_send_files_way.h" @@ -62,6 +63,10 @@ class CharactersLimitLabel; class ComposeAiButton; } // namespace HistoryView::Controls +namespace SendFiles { +class ReplyPillHeader; +} // namespace SendFiles + enum class SendFilesAllow { OnlyOne = (1 << 0), Photos = (1 << 1), @@ -91,7 +96,8 @@ using SendFilesCheck = Fn, - Api::SendOptions)>; + Api::SendOptions, + FullReplyTo)>; struct SendFilesBoxDescriptor { std::shared_ptr show; @@ -105,6 +111,7 @@ struct SendFilesBoxDescriptor { const style::ComposeControls *stOverride = nullptr; SendFilesConfirmed confirmed; Fn cancelled; + FullReplyTo replyTo; Fn cancelled2; }; @@ -131,6 +138,7 @@ public: void setCancelledCallback(Fn callback) { _cancelledCallback = std::move(callback); } + void setReplyTo(FullReplyTo replyTo); [[nodiscard]] rpl::producer takeTextWithTagsRequests() const; @@ -166,6 +174,7 @@ private: [[nodiscard]] int fromIndex() const; [[nodiscard]] int tillIndex() const; + [[nodiscard]] bool isSingleFile() const; [[nodiscard]] object_ptr takeWidget(); [[nodiscard]] rpl::producer itemDeleteRequest() const; @@ -263,7 +272,7 @@ private: void checkCharsLimitation(); void refreshMessagesCount(); - void requestToTakeTextWithTags() const; + void requestToTakeTextWithTags(); bool validateLength(const QString &text) const; [[nodiscard]] Fn prepareSendMenuDetails( @@ -297,6 +306,7 @@ private: std::unique_ptr _priceTag; QImage _priceTagBg; bool _confirmed = false; + bool _textTaken = false; bool _invertCaption = false; const object_ptr _caption; @@ -318,6 +328,10 @@ private: rpl::variable _footerHeight = 0; rpl::lifetime _dimensionsLifetime; + std::unique_ptr _replyHeader; + rpl::variable _replyHeaderHeight = 0; + FullReplyTo _replyTo; + object_ptr _scroll; QPointer _inner; std::deque _blocks; diff --git a/Telegram/SourceFiles/boxes/send_files_box_reply_header.cpp b/Telegram/SourceFiles/boxes/send_files_box_reply_header.cpp new file mode 100644 index 0000000000..881ca1fb7f --- /dev/null +++ b/Telegram/SourceFiles/boxes/send_files_box_reply_header.cpp @@ -0,0 +1,372 @@ +/* +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/send_files_box_reply_header.h" + +#include "chat_helpers/compose/compose_show.h" +#include "core/ui_integration.h" +#include "data/data_changes.h" +#include "data/data_media_types.h" +#include "data/data_session.h" +#include "history/history.h" +#include "history/history_item.h" +#include "history/view/history_view_reply.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "ui/chat/chat_style.h" +#include "ui/effects/spoiler_mess.h" +#include "ui/image/image.h" +#include "ui/painter.h" +#include "ui/text/text_options.h" +#include "ui/power_saving.h" +#include "ui/widgets/buttons.h" +#include "window/window_session_controller.h" +#include "apiwrap.h" +#include "styles/style_boxes.h" +#include "styles/style_chat.h" +#include "styles/style_chat_helpers.h" +#include "styles/style_dialogs.h" + +namespace SendFiles { +namespace { + +constexpr auto kAnimationDuration = crl::time(180); + +} // namespace + +ReplyPillHeader::ReplyPillHeader( + QWidget *parent, + std::shared_ptr show, + FullReplyTo replyTo) +: RpWidget(parent) +, _show(std::move(show)) +, _data(&_show->session().data()) +, _replyTo(std::move(replyTo)) +, _cancel(Ui::CreateChild(this, st::sendFilesReplyCancel)) { + resize( + parent->width(), + st::boxPhotoCaptionSkip + st::historyReplyHeight); + + _cancel->setAccessibleName(tr::lng_cancel(tr::now)); + _cancel->setClickedCallback([=] { + hideAnimated(); + }); + + setShownMessage(_data->message(_replyTo.messageId)); + + _data->session().changes().messageUpdates( + Data::MessageUpdate::Flag::Edited + | Data::MessageUpdate::Flag::Destroyed + ) | rpl::filter([=](const Data::MessageUpdate &update) { + return (update.item == _shownMessage); + }) | rpl::on_next([=](const Data::MessageUpdate &update) { + if (update.flags & Data::MessageUpdate::Flag::Destroyed) { + _shownMessage = nullptr; + _shownMessageName.clear(); + _shownMessageText.clear(); + hideAnimated(); + } else { + updateShownMessageText(); + RpWidget::update(); + } + }, lifetime()); + + animationCallback(); +} + +ReplyPillHeader::~ReplyPillHeader() = default; + +rpl::producer<> ReplyPillHeader::closeRequests() const { + return _closeRequests.events(); +} + +rpl::producer<> ReplyPillHeader::hideFinished() const { + return _hideFinished.value() + | rpl::filter(rpl::mappers::_1) + | rpl::to_empty; +} + +rpl::producer ReplyPillHeader::desiredHeight() const { + return _desiredHeight.value(); +} + +void ReplyPillHeader::setRoundedShapeBelow(bool value) { + if (_roundedShapeBelow == value) { + return; + } + _roundedShapeBelow = value; + update(); +} + +void ReplyPillHeader::hideAnimated() { + if (_hiding) { + return; + } + _hiding = true; + _closeRequests.fire({}); + _showAnimation.start( + [=] { animationCallback(); }, + 1., + 0., + kAnimationDuration); +} + +void ReplyPillHeader::animationCallback() { + const auto full = st::boxPhotoCaptionSkip + st::historyReplyHeight; + const auto value = _showAnimation.value(_hiding ? 0. : 1.); + _desiredHeight = int(base::SafeRound(full * value)); + update(); + if (_hiding && !_showAnimation.animating()) { + _hideFinished = true; + } +} + +void ReplyPillHeader::resolveMessageData() { + const auto id = _replyTo.messageId; + if (!id || !id.peer) { + return; + } + const auto peer = _data->peer(id.peer); + const auto itemId = id.msg; + const auto callback = crl::guard(this, [=] { + if (!_shownMessage) { + if (const auto message = _data->message(peer, itemId)) { + setShownMessage(message); + } else { + hideAnimated(); + } + } + }); + _data->session().api().requestMessageData(peer, itemId, callback); +} + +void ReplyPillHeader::setShownMessage(HistoryItem *item) { + _shownMessage = item; + if (item) { + updateShownMessageText(); + const auto context = Core::TextContext({ + .session = &item->history()->session(), + .customEmojiLoopLimit = 1, + }); + _shownMessageName.setMarkedText( + st::fwdTextStyle, + HistoryView::Reply::ComposePreviewName( + item->history(), + item, + _replyTo), + Ui::NameTextOptions(), + context); + } else { + _shownMessageName.clear(); + _shownMessageText.clear(); + resolveMessageData(); + } + update(); +} + +void ReplyPillHeader::updateShownMessageText() { + Expects(_shownMessage != nullptr); + + const auto context = Core::TextContext({ + .session = &_data->session(), + .repaint = [=] { customEmojiRepaint(); }, + }); + _shownMessageText.setMarkedText( + st::messageTextStyle, + (_replyTo.quote.empty() + ? _shownMessage->inReplyText() + : _replyTo.quote), + Ui::DialogTextOptions(), + context); +} + +void ReplyPillHeader::customEmojiRepaint() { + if (_repaintScheduled) { + return; + } + _repaintScheduled = true; + update(); +} + +void ReplyPillHeader::resizeEvent(QResizeEvent *e) { + _cancel->moveToRight( + st::boxPhotoPadding.right() + st::sendBoxAlbumGroupSkipRight, + (st::historyReplyHeight - _cancel->height()) / 2); +} + +void ReplyPillHeader::paintEvent(QPaintEvent *e) { + _repaintScheduled = false; + + Painter p(this); + p.setInactive(_show->paused(Window::GifPauseReason::Layer)); + + const auto left = st::boxPhotoPadding.left(); + const auto right = st::boxPhotoPadding.right(); + const auto bottomSkip = st::boxPhotoCaptionSkip; + const auto pillHeight = height() - bottomSkip; + if (pillHeight <= 0) { + return; + } + const auto pillRect = QRect( + left, + 0, + width() - left - right, + pillHeight); + if (pillRect.isEmpty()) { + return; + } + + { + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(st::windowBgOver); + const auto topRadius = st::bubbleRadiusLarge; + const auto bottomRadius = _roundedShapeBelow + ? st::bubbleRadiusSmall + : st::bubbleRadiusLarge; + const auto rectF = QRectF(pillRect); + auto path = QPainterPath(); + path.moveTo(rectF.left() + topRadius, rectF.top()); + path.lineTo(rectF.right() - topRadius, rectF.top()); + path.arcTo( + rectF.right() - 2 * topRadius, + rectF.top(), + 2 * topRadius, + 2 * topRadius, + 90, -90); + path.lineTo(rectF.right(), rectF.bottom() - bottomRadius); + path.arcTo( + rectF.right() - 2 * bottomRadius, + rectF.bottom() - 2 * bottomRadius, + 2 * bottomRadius, + 2 * bottomRadius, + 0, -90); + path.lineTo(rectF.left() + bottomRadius, rectF.bottom()); + path.arcTo( + rectF.left(), + rectF.bottom() - 2 * bottomRadius, + 2 * bottomRadius, + 2 * bottomRadius, + 270, -90); + path.lineTo(rectF.left(), rectF.top() + topRadius); + path.arcTo( + rectF.left(), + rectF.top(), + 2 * topRadius, + 2 * topRadius, + 180, -90); + path.closeSubpath(); + p.fillPath(path, st::windowBgOver); + } + + const auto iconPos = st::sendFilesReplyIconPosition + + QPoint(pillRect.left(), pillRect.top()); + if (!_replyTo.quote.empty()) { + st::historyQuoteIcon.paint(p, iconPos, width()); + } else { + st::historyReplyIcon.paint(p, iconPos, width()); + // Remove 'settings' mini-icon. + p.fillRect( + QRect( + QPoint(style::ConvertScale(16), style::ConvertScale(5)) + + iconPos, + QSize(style::ConvertScale(11), style::ConvertScale(8))), + st::windowBgOver); + p.fillRect( + QRect( + QPoint(style::ConvertScale(22), style::ConvertScale(13)) + + iconPos, + QSize(style::ConvertScale(5), style::ConvertScale(2))), + st::windowBgOver); + } + + const auto replySkip = st::historyReplySkip; + const auto textLeft = pillRect.left() + replySkip; + const auto availableWidth = _cancel->x() - textLeft; + if (availableWidth <= 0) { + return; + } + + const auto pillCenterY = pillRect.top() + + st::historyReplyHeight / 2; + + if (!_shownMessage) { + p.setFont(st::msgDateFont); + p.setPen(st::historyComposeAreaFgService); + const auto top = pillCenterY - st::msgDateFont->height / 2; + p.drawText( + textLeft, + top + st::msgDateFont->ascent, + st::msgDateFont->elided( + tr::lng_profile_loading(tr::now), + availableWidth)); + return; + } + + const auto media = _shownMessage->media(); + const auto hasPreview = media && media->hasReplyPreview(); + const auto preview = hasPreview ? media->replyPreview() : nullptr; + const auto spoilered = media && media->hasSpoiler(); + if (!spoilered) { + _previewSpoiler = nullptr; + } else if (!_previewSpoiler) { + _previewSpoiler = std::make_unique([=] { + update(); + }); + } + const auto previewSkipValue = st::historyReplyPreview + st::msgReplyBarSkip; + const auto previewSkip = (hasPreview && preview) ? previewSkipValue : 0; + const auto contentLeft = textLeft + previewSkip; + const auto contentAvailable = availableWidth - previewSkip; + + if (preview) { + const auto to = QRect( + textLeft, + pillCenterY - st::historyReplyPreview / 2, + st::historyReplyPreview, + st::historyReplyPreview); + p.drawPixmap(to.x(), to.y(), preview->pixSingle( + preview->size() / style::DevicePixelRatio(), + { + .options = Images::Option::RoundSmall, + .outer = to.size(), + })); + if (_previewSpoiler) { + Ui::FillSpoilerRect( + p, + to, + Ui::DefaultImageSpoiler().frame( + _previewSpoiler->index(crl::now(), p.inactive()))); + } + } + + p.setPen(st::historyReplyNameFg); + p.setFont(st::msgServiceNameFont); + _shownMessageName.drawElided( + p, + contentLeft, + pillRect.top() + st::msgReplyPadding.top(), + contentAvailable); + + p.setPen(st::historyComposeAreaFg); + _shownMessageText.draw(p, { + .position = QPoint( + contentLeft, + pillRect.top() + + st::msgReplyPadding.top() + + st::msgServiceNameFont->height), + .availableWidth = contentAvailable, + .palette = &st::historyComposeAreaPalette, + .spoiler = Ui::Text::DefaultSpoilerCache(), + .now = crl::now(), + .pausedEmoji = p.inactive() || On(PowerSaving::kEmojiChat), + .pausedSpoiler = p.inactive() || On(PowerSaving::kChatSpoiler), + .elisionLines = 1, + }); +} + +} // namespace SendFiles diff --git a/Telegram/SourceFiles/boxes/send_files_box_reply_header.h b/Telegram/SourceFiles/boxes/send_files_box_reply_header.h new file mode 100644 index 0000000000..9515c896fd --- /dev/null +++ b/Telegram/SourceFiles/boxes/send_files_box_reply_header.h @@ -0,0 +1,77 @@ +/* +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/effects/animations.h" +#include "ui/rp_widget.h" +#include "ui/text/text.h" + +class HistoryItem; + +namespace ChatHelpers { +class Show; +} // namespace ChatHelpers + +namespace Data { +class Session; +} // namespace Data + +namespace Ui { +class IconButton; +class SpoilerAnimation; +} // namespace Ui + +namespace SendFiles { + +class ReplyPillHeader final : public Ui::RpWidget { +public: + ReplyPillHeader( + QWidget *parent, + std::shared_ptr show, + FullReplyTo replyTo); + ~ReplyPillHeader(); + + [[nodiscard]] rpl::producer<> closeRequests() const; + [[nodiscard]] rpl::producer<> hideFinished() const; + [[nodiscard]] rpl::producer desiredHeight() const; + + void setRoundedShapeBelow(bool value); + void hideAnimated(); + +protected: + void paintEvent(QPaintEvent *e) override; + void resizeEvent(QResizeEvent *e) override; + +private: + void resolveMessageData(); + void setShownMessage(HistoryItem *item); + void updateShownMessageText(); + void customEmojiRepaint(); + void animationCallback(); + + const std::shared_ptr _show; + const not_null _data; + const FullReplyTo _replyTo; + const not_null _cancel; + + HistoryItem *_shownMessage = nullptr; + Ui::Text::String _shownMessageName; + Ui::Text::String _shownMessageText; + std::unique_ptr _previewSpoiler; + bool _repaintScheduled = false; + + Ui::Animations::Simple _showAnimation; + rpl::variable _desiredHeight = 0; + rpl::event_stream<> _closeRequests; + rpl::variable _hideFinished = false; + bool _hiding = false; + bool _roundedShapeBelow = true; + +}; + +} // namespace SendFiles diff --git a/Telegram/SourceFiles/boxes/share_box.cpp b/Telegram/SourceFiles/boxes/share_box.cpp index 705de7dbd2..96299aaa88 100644 --- a/Telegram/SourceFiles/boxes/share_box.cpp +++ b/Telegram/SourceFiles/boxes/share_box.cpp @@ -2068,16 +2068,18 @@ void FastShareMessageToSelf( std::shared_ptr show, not_null item) { const auto self = show->session().user(); + auto &owner = self->owner(); + const auto items = owner.idsToItems(owner.itemOrItsGroup(item)); const auto donePhraseArgs = ChatHelpers::ForwardedMessagePhraseArgs{ .toCount = 1, - .singleMessage = true, + .singleMessage = (items.size() == 1), .to1 = self, .to2 = nullptr, }; - auto sendAction = Api::SendAction(self->owner().history(self)); + auto sendAction = Api::SendAction(owner.history(self)); sendAction.clearDraft = false; show->session().api().forwardMessages( - Data::ResolvedForwardDraft{ .items = {item} }, + Data::ResolvedForwardDraft{ .items = items }, std::move(sendAction), [=] { auto phrase = rpl::variable( diff --git a/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp b/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp index e901ec135c..9f0aa64bd0 100644 --- a/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp +++ b/Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp @@ -55,7 +55,6 @@ constexpr auto kSuccessFadeInDuration = crl::time(300); constexpr auto kSuccessExpandDuration = crl::time(400); constexpr auto kSuccessExpandStart = crl::time(100); constexpr auto kProgressFadeInDuration = crl::time(300); -constexpr auto kFailureFadeInDuration = crl::time(300); [[nodiscard]] QString FormatPercent(int permille) { const auto rounded = (permille + 5) / 10; diff --git a/Telegram/SourceFiles/boxes/star_gift_preview_box.cpp b/Telegram/SourceFiles/boxes/star_gift_preview_box.cpp index 72ac5b9d32..c6c9f65ac6 100644 --- a/Telegram/SourceFiles/boxes/star_gift_preview_box.cpp +++ b/Telegram/SourceFiles/boxes/star_gift_preview_box.cpp @@ -120,7 +120,6 @@ private: QColor _patternColor; Ui::Animations::Simple _selectedAnimation; bool _selected : 1 = false; - bool _patterned : 1 = false; QMargins _extend; diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.cpp b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp new file mode 100644 index 0000000000..6a0c116de9 --- /dev/null +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.cpp @@ -0,0 +1,389 @@ +/* +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/sticker_creator_box.h" + +#include "api/api_stickers_creator.h" +#include "chat_helpers/compose/compose_show.h" +#include "chat_helpers/emoji_picker_overlay.h" +#include "core/file_utilities.h" +#include "editor/editor_layer_widget.h" +#include "editor/photo_editor.h" +#include "editor/photo_editor_common.h" +#include "editor/scene/scene.h" +#include "editor/scene/scene_item_image.h" +#include "info/channel_statistics/boosts/giveaway/boost_badge.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "ui/emoji_config.h" +#include "ui/image/image.h" +#include "ui/image/image_prepare.h" +#include "ui/layers/generic_box.h" +#include "ui/layers/layer_widget.h" +#include "ui/painter.h" +#include "ui/rp_widget.h" +#include "ui/vertical_list.h" +#include "ui/widgets/buttons.h" +#include "ui/wrap/vertical_layout.h" +#include "window/window_controller.h" +#include "window/window_session_controller.h" +#include "styles/style_boxes.h" +#include "styles/style_chat_helpers.h" +#include "styles/style_editor.h" +#include "styles/style_layers.h" + +#include +#include + +namespace { + +constexpr auto kStickerSide = 512; +constexpr auto kPreviewSide = 256; +constexpr auto kWebpQuality = 95; +constexpr auto kMaxEmojis = 7; + +[[nodiscard]] QImage LoadImageFromFile(const QString &path) { + auto reader = QImageReader(path); + reader.setAutoTransform(true); + auto image = reader.read(); + if (image.format() != QImage::Format_ARGB32_Premultiplied + && image.format() != QImage::Format_ARGB32) { + image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + return image; +} + +class PreviewWidget final : public Ui::RpWidget { +public: + PreviewWidget(QWidget *parent, QImage image) + : RpWidget(parent) + , _image(std::move(image)) { + resize(kPreviewSide, kPreviewSide); + } + +protected: + void paintEvent(QPaintEvent *e) override { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + const auto target = QRect(0, 0, width(), height()); + p.drawImage(target, _image); + } + +private: + const QImage _image; + +}; + +void OpenPhotoEditorForSticker( + std::shared_ptr show, + QImage image, + Fn onDone) { + if (image.isNull()) { + show->showToast(tr::lng_stickers_create_open_failed(tr::now)); + return; + } + const auto sessionController = show->resolveWindow(); + if (!sessionController) { + show->showToast(tr::lng_stickers_create_open_failed(tr::now)); + return; + } + const auto windowController = &sessionController->window(); + const auto parentWidget = sessionController->widget(); + + if (image.width() <= 0 + || image.height() <= 0 + || (image.width() > 10 * image.height()) + || (image.height() > 10 * image.width())) { + show->showToast(tr::lng_stickers_create_open_failed(tr::now)); + return; + } + + auto canvas = QImage( + kStickerSide, + kStickerSide, + QImage::Format_ARGB32_Premultiplied); + canvas.fill(Qt::transparent); + const auto baseImage = std::make_shared(std::move(canvas)); + + auto scene = std::make_shared( + QRectF(0, 0, kStickerSide, kStickerSide)); + + const auto userPixmap = QPixmap::fromImage(std::move(image)); + const auto userSize = userPixmap.size(); + const auto fitted = userSize.scaled( + QSize(kStickerSide, kStickerSide), + Qt::KeepAspectRatio); + const auto handle = st::photoEditorItemHandleSize; + const auto itemSize = (userSize.width() >= userSize.height()) + ? int((fitted.height() + handle) + * userSize.width() / float64(userSize.height())) + : (fitted.width() + handle); + auto itemData = Editor::ItemBase::Data{ + .initialZoom = 1.0, + .zPtr = scene->lastZ(), + .size = itemSize, + .x = kStickerSide / 2, + .y = kStickerSide / 2, + .imageSize = userSize, + }; + auto imageItem = std::make_shared( + QPixmap(userPixmap), + std::move(itemData)); + scene->addItem(std::move(imageItem)); + + auto modifications = Editor::PhotoModifications{ + .crop = QRect(0, 0, kStickerSide, kStickerSide), + .paint = std::move(scene), + }; + + auto editor = base::make_unique_q( + parentWidget, + windowController, + baseImage, + std::move(modifications), + Editor::EditorData{ + .exactSize = QSize(kStickerSide, kStickerSide), + .cropType = Editor::EditorData::CropType::RoundedRect, + .keepAspectRatio = true, + .fixedCrop = true, + }); + const auto raw = editor.get(); + + auto applyModifications = [=, done = std::move(onDone)]( + const Editor::PhotoModifications &mods) mutable { + auto result = Editor::ImageModified(baseImage->original(), mods); + if (result.size() != QSize(kStickerSide, kStickerSide)) { + result = result.scaled( + kStickerSide, + kStickerSide, + Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + } + done(std::move(result)); + }; + + auto layer = std::make_unique( + parentWidget, + std::move(editor)); + Editor::InitEditorLayer(layer.get(), raw, std::move(applyModifications)); + windowController->showLayer( + std::move(layer), + Ui::LayerOption::KeepOther); +} + +[[nodiscard]] QByteArray EncodeWebp(QImage image) { + if (image.size() != QSize(kStickerSide, kStickerSide)) { + image = image.scaled( + kStickerSide, + kStickerSide, + Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + } + if (image.format() != QImage::Format_ARGB32) { + image = image.convertToFormat(QImage::Format_ARGB32); + } + auto bytes = QByteArray(); + auto buffer = QBuffer(&bytes); + buffer.open(QIODevice::WriteOnly); + image.save(&buffer, "WEBP", kWebpQuality); + return bytes; +} + +} // namespace + +namespace Api { + +void CreateStickerBox( + not_null box, + std::shared_ptr show, + StickerSetIdentifier set, + QImage image, + Fn done) { + struct State { + rpl::variable uploading = false; + std::unique_ptr upload; + QPointer addButton; + }; + const auto state = box->lifetime().make_state(); + const auto session = &show->session(); + + box->setTitle(tr::lng_stickers_create_image_title()); + + const auto inner = box->verticalLayout(); + + auto pickerDescriptor = ChatHelpers::EmojiPickerOverlayDescriptor{ + .aboutText = tr::lng_stickers_create_emoji_about(tr::now), + .maxSelected = kMaxEmojis, + .allowExpand = true, + }; + const auto metrics = ChatHelpers::EmojiPickerOverlay::EstimateMetrics( + pickerDescriptor.aboutText); + const auto pickerCollapsed = metrics.collapsedHeight; + const auto pickerTotalExpanded = metrics.totalExpandedHeight; + const auto shadowExt = metrics.shadowExtent; + + constexpr auto kStickerOverlap = 24; + const auto stickerTop = shadowExt.top() + + pickerCollapsed + - kStickerOverlap; + const auto holderHeight = std::max( + stickerTop + kPreviewSide, + pickerTotalExpanded); + + const auto previewHolder = inner->add( + object_ptr(inner), + QMargins(0, 0, 0, 0), + style::al_top); + previewHolder->resize(st::boxWideWidth, holderHeight); + const auto preview = Ui::CreateChild( + previewHolder, + image); + + const auto picker = Ui::CreateChild( + previewHolder, + std::move(pickerDescriptor)); + + auto layoutOverlay = [=] { + const auto bubbleW = std::min( + previewHolder->width() + - 2 * st::boxRowPadding.left() + - shadowExt.left() - shadowExt.right(), + int(kPreviewSide * 1.1)); + const auto totalW = bubbleW + shadowExt.left() + shadowExt.right(); + const auto x = (previewHolder->width() - totalW) / 2; + picker->setGeometry(x, 0, totalW, pickerTotalExpanded); + picker->raise(); + }; + + previewHolder->widthValue( + ) | rpl::on_next([=](int width) { + preview->move((width - kPreviewSide) / 2, stickerTop); + layoutOverlay(); + }, preview->lifetime()); + + Ui::AddSkip(inner); + + const auto startUpload = [=, set = std::move(set), done = std::move(done)]( + ) mutable { + if (state->uploading.current()) { + return; + } + auto emoji = QString(); + for (const auto one : picker->selected()) { + emoji.append(one->text()); + } + if (emoji.isEmpty()) { + show->showToast( + tr::lng_stickers_create_emoji_required(tr::now)); + return; + } + const auto bytes = EncodeWebp(image); + if (bytes.isEmpty()) { + show->showToast( + tr::lng_stickers_create_upload_failed(tr::now)); + return; + } + + const auto lockedWidth = state->addButton + ? state->addButton->width() + : 0; + state->uploading = true; + if (state->addButton && lockedWidth > 0) { + state->addButton->resizeToWidth(lockedWidth); + } + state->upload = std::make_unique( + session, + set, + bytes, + emoji); + + const auto doneCallback = done; + state->upload->start( + crl::guard(box, [=](MTPmessages_StickerSet result) { + state->upload = nullptr; + state->uploading = false; + show->showToast(tr::lng_stickers_create_added(tr::now)); + if (doneCallback) { + doneCallback(result); + } + box->closeBox(); + }), + crl::guard(box, [=](QString err) { + state->upload = nullptr; + state->uploading = false; + show->showToast(err.isEmpty() + ? tr::lng_stickers_create_upload_failed(tr::now) + : err); + })); + }; + + const auto addButton = box->addButton( + rpl::conditional( + state->uploading.value(), + rpl::single(QString()), + tr::lng_box_done()), + startUpload); + state->addButton = addButton; + box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); + + { + using namespace Info::Statistics; + const auto loadingAnimation = InfiniteRadialAnimationWidget( + addButton, + addButton->height() / 2, + &st::editStickerSetNameLoading); + AddChildToWidgetCenter(addButton, loadingAnimation); + loadingAnimation->showOn(state->uploading.value()); + } + + box->setWidth(st::boxWideWidth); + + box->boxClosing( + ) | rpl::on_next([=] { + state->upload = nullptr; + }, box->lifetime()); +} + +void OpenCreateStickerFlow( + std::shared_ptr show, + StickerSetIdentifier set, + Fn done) { + const auto parent = QPointer(show->toastParent()); + + const auto onChosen = [=, set = std::move(set), done = std::move(done)]( + FileDialog::OpenResult &&result) mutable { + if (result.paths.isEmpty() && result.remoteContent.isEmpty()) { + return; + } + const auto path = result.paths.isEmpty() + ? QString() + : result.paths.front(); + auto image = path.isEmpty() + ? QImage::fromData(result.remoteContent) + : LoadImageFromFile(path); + OpenPhotoEditorForSticker( + show, + std::move(image), + [=, set = std::move(set), done = std::move(done)]( + QImage &&prepared) mutable { + show->showBox(Box( + CreateStickerBox, + show, + std::move(set), + std::move(prepared), + std::move(done))); + }); + }; + + FileDialog::GetOpenPath( + parent, + tr::lng_stickers_create_choose_image(tr::now), + FileDialog::ImagesFilter(), + std::move(onChosen)); +} + +} // namespace Api diff --git a/Telegram/SourceFiles/boxes/sticker_creator_box.h b/Telegram/SourceFiles/boxes/sticker_creator_box.h new file mode 100644 index 0000000000..75c2a3648a --- /dev/null +++ b/Telegram/SourceFiles/boxes/sticker_creator_box.h @@ -0,0 +1,36 @@ +/* +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 "data/stickers/data_stickers.h" + +#include + +namespace ChatHelpers { +class Show; +} // namespace ChatHelpers + +namespace Ui { +class GenericBox; +} // namespace Ui + +namespace Api { + +void CreateStickerBox( + not_null box, + std::shared_ptr show, + StickerSetIdentifier set, + QImage image, + Fn done); + +void OpenCreateStickerFlow( + std::shared_ptr show, + StickerSetIdentifier set, + Fn done = nullptr); + +} // namespace Api diff --git a/Telegram/SourceFiles/boxes/sticker_set_box.cpp b/Telegram/SourceFiles/boxes/sticker_set_box.cpp index 18edd80797..08990526b5 100644 --- a/Telegram/SourceFiles/boxes/sticker_set_box.cpp +++ b/Telegram/SourceFiles/boxes/sticker_set_box.cpp @@ -8,14 +8,17 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/sticker_set_box.h" #include "api/api_common.h" +#include "api/api_stickers_creator.h" #include "api/api_toggling_media.h" #include "apiwrap.h" #include "base/unixtime.h" #include "boxes/premium_preview_box.h" +#include "boxes/sticker_creator_box.h" #include "chat_helpers/compose/compose_show.h" #include "chat_helpers/stickers_list_widget.h" #include "chat_helpers/stickers_lottie.h" #include "core/application.h" +#include "core/click_handler_types.h" #include "data/data_document.h" #include "data/data_document_media.h" #include "data/data_file_origin.h" @@ -52,11 +55,18 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/buttons.h" #include "ui/widgets/fields/input_field.h" #include "ui/widgets/gradient_round_button.h" +#include "ui/widgets/menu/menu_action.h" #include "ui/widgets/menu/menu_add_action_callback.h" #include "ui/widgets/menu/menu_add_action_callback_factory.h" +#include "ui/widgets/menu/menu_multiline_action.h" +#include "base/event_filter.h" +#include "chat_helpers/tabbed_panel.h" +#include "chat_helpers/tabbed_selector.h" +#include "ui/widgets/inner_dropdown.h" #include "ui/widgets/popup_menu.h" #include "ui/widgets/scroll_area.h" #include "window/window_session_controller.h" +#include "styles/style_chat.h" #include "styles/style_layers.h" #include "styles/style_chat_helpers.h" #include "styles/style_info.h" @@ -333,6 +343,7 @@ public: } void applySet(const TLStickerSet &set); + void setOuterContainer(QPointer container); ~Inner(); @@ -390,6 +401,16 @@ private: void startOverAnimation(int index, float64 from, float64 to); int stickerFromGlobalPos(const QPoint &p) const; + [[nodiscard]] bool hasAddCell() const; + [[nodiscard]] int totalCellsCount() const; + [[nodiscard]] QRect addCellRect() const; + [[nodiscard]] bool addCellFromGlobalPos(const QPoint &p) const; + void setAddCellHovered(bool hovered); + void paintAddCell(QPainter &p) const; + void showAddMenu(QPoint globalPos); + void startAddExistingStickerFlow(); + void startCreateNewStickerFlow(); + void installDone(const MTPmessages_StickerSetInstallResult &result); void requestReorder(not_null document, int index); @@ -473,6 +494,8 @@ private: mtpRequestId _installRequest = 0; int _selected = -1; + bool _addCellHovered = false; + bool _addCellPressed = false; base::Timer _previewTimer; int _previewShown = -1; @@ -480,6 +503,8 @@ private: bool _previewLocked = false; base::unique_qptr _menu; + base::unique_qptr _pickerPanel; + QPointer _outerContainer; rpl::event_stream _setInstalled; rpl::event_stream _setArchived; @@ -533,6 +558,7 @@ void StickerSetBox::prepare() { _inner = setInnerWidget( object_ptr(this, _show, _set, _type), st::stickersScroll); + _inner->setOuterContainer(getDelegate()->outerContainer()); if (const auto previewId = base::take(_previewDocumentId)) { _inner->showPreviewForDocument(previewId); } @@ -779,7 +805,99 @@ void StickerSetBox::updateButtons() { &st::menuIconReorder); }); }(); - const auto addPackIdActions = [=](const std::shared_ptr> &menu) + const auto fillSetCreatorFooter = [&] { + using Filler = Fn)>; + if (!_inner->amSetCreator()) { + return Filler(nullptr); + } + const auto data = &_session->data(); + return Filler([=, show = _show, set = _set]( + not_null menu) { + const auto weak = base::weak_qptr(this); + const auto deleteEveryone = [=] { + const auto confirm = [=](Fn close) { + Api::DeleteStickerSet( + &data->session(), + set, + [=] { + if (const auto strong = weak.get()) { + strong->closeBox(); + } + }, + [=](const QString &error) { + show->showToast(error); + }); + close(); + }; + show->showBox(Ui::MakeConfirmBox({ + .text = tr::lng_stickers_delete_pack_sure(tr::now), + .confirmed = confirm, + .confirmText + = tr::lng_stickers_remove_pack_confirm(), + .confirmStyle = &st::attentionBoxButton, + })); + }; + const auto deleteSelf = [show, inner = _inner] { + const auto raw = inner.data(); + if (!raw) { + return; + } + auto box = ChatHelpers::MakeConfirmRemoveSetBox( + &show->session(), + st::boxLabel, + raw->setId()); + if (box) { + show->showBox(std::move(box)); + } + }; + const auto deleteAction = menu->addAction( + base::make_unique_q( + menu->menu(), + st::menuWithIconsAttention, + Ui::Menu::CreateAction( + menu->menu().get(), + tr::lng_stickers_context_delete_pack(tr::now), + nullptr), + &st::menuIconDeleteAttention, + &st::menuIconDeleteAttention)); + deleteAction->setMenu( + Ui::CreateChild(menu->menu().get())); + const auto sub = menu->ensureSubmenu( + deleteAction, + st::popupMenuWithIcons); + const auto addSub = Ui::Menu::CreateAddActionCallback(sub); + addSub({ + .text = tr::lng_stickers_context_delete_pack_everyone( + tr::now), + .handler = deleteEveryone, + .icon = &st::menuIconDeleteAttention, + .isAttention = true, + }); + sub->addAction( + tr::lng_stickers_context_delete_pack_self(tr::now), + deleteSelf, + &st::menuIconRemove); + menu->addSeparator(&st::expandedMenuSeparator); + auto item = base::make_unique_q( + menu->menu(), + st::defaultMenu, + st::historyHasCustomEmoji, + QPoint( + st::defaultMenu.itemPadding.left(), + st::defaultMenu.itemPadding.top()), + tr::lng_stickers_bot_more_options( + tr::now, + lt_bot, + Ui::Text::Colorized(tr::bold(u"@stickers"_q)), + Ui::Text::RichLangValue)); + item->clicks( + ) | rpl::on_next([] { + UrlClickHandler::Open(u"https://t.me/stickers"_q); + }, item->lifetime()); + menu->addAction(std::move(item)); + }); + }(); + const auto addPackIdActions = [=](Ui::PopupMenu *menu) { if (type == Data::StickersType::Stickers || type == Data::StickersType::Emoji) { const auto &settings = AyuSettings::getInstance(); @@ -788,7 +906,7 @@ void StickerSetBox::updateButtons() { const auto setId = _inner->setId(); const auto innerId = setId >> 32; - (*menu)->addAction( + menu->addAction( tr::ayu_MessageDetailsPackOwnerPC(tr::now), [weak, session, innerId] { @@ -831,7 +949,7 @@ void StickerSetBox::updateButtons() { &st::menuIconProfile); if (settings.showPeerId() != PeerIdDisplay::Hidden) { - (*menu)->addAction( + menu->addAction( tr::ayu_ContextCopyID(tr::now), [weak, setId] { @@ -881,16 +999,17 @@ void StickerSetBox::updateButtons() { if (!_inner->shortName().isEmpty()) { const auto top = addTopButton(st::infoTopBarMenu); - const auto menu - = std::make_shared>(); + const auto menu = top->lifetime().make_state< + base::unique_qptr>(); top->setClickedCallback([=] { *menu = base::make_unique_q( top, st::popupMenuWithIcons); + const auto raw = menu->get(); if (fillSetCreatorMenu) { - fillSetCreatorMenu(*menu); + fillSetCreatorMenu(raw); } - (*menu)->addAction( + raw->addAction( ((type == Data::StickersType::Emoji) ? tr::lng_stickers_share_emoji : (type == Data::StickersType::Masks) @@ -898,8 +1017,21 @@ void StickerSetBox::updateButtons() { : tr::lng_stickers_share_pack)(tr::now), [=] { share(); closeBox(); }, &st::menuIconShare); - addPackIdActions(menu); - (*menu)->popup(QCursor::pos()); + addPackIdActions(raw); + if (fillSetCreatorFooter) { + fillSetCreatorFooter(raw); + } + raw->setForcedOrigin( + Ui::PanelAnimation::Origin::TopRight); + top->setForceRippled(true); + raw->setDestroyedCallback([=] { + if (const auto strong = top.data()) { + strong->setForceRippled(false); + } + }); + raw->popup(top->mapToGlobal(QPoint( + top->width(), + top->height() - st::lineWidth * 3))); return true; }); } @@ -929,30 +1061,51 @@ void StickerSetBox::updateButtons() { _show->showBox(std::move(box)); } }; - const auto menu - = std::make_shared>(); + const auto menu = top->lifetime().make_state< + base::unique_qptr>(); top->setClickedCallback([=] { *menu = base::make_unique_q( top, st::popupMenuWithIcons); + const auto raw = menu->get(); if (type == Data::StickersType::Emoji) { - (*menu)->addAction( - tr::lng_custom_emoji_remove_pack_button(tr::now), - remove, - &st::menuIconRemove); + if (fillSetCreatorMenu) { + fillSetCreatorMenu(raw); + } + if (fillSetCreatorFooter) { + fillSetCreatorFooter(raw); + } else { + raw->addAction( + tr::lng_custom_emoji_remove_pack_button(tr::now), + remove, + &st::menuIconRemove); + } } else { if (fillSetCreatorMenu) { - fillSetCreatorMenu(*menu); + fillSetCreatorMenu(raw); } - (*menu)->addAction( + raw->addAction( (type == Data::StickersType::Masks ? tr::lng_masks_archive_pack(tr::now) : tr::lng_stickers_archive_pack(tr::now)), archive, &st::menuIconArchive); + if (fillSetCreatorFooter) { + fillSetCreatorFooter(raw); + } } - addPackIdActions(menu); - (*menu)->popup(QCursor::pos()); + addPackIdActions(raw); + raw->setForcedOrigin( + Ui::PanelAnimation::Origin::TopRight); + top->setForceRippled(true); + raw->setDestroyedCallback([=] { + if (const auto strong = top.data()) { + strong->setForceRippled(false); + } + }); + raw->popup(top->mapToGlobal(QPoint( + top->width(), + top->height() - st::lineWidth * 3))); return true; }); } @@ -1095,7 +1248,8 @@ void StickerSetBox::Inner::applySet(const TLStickerSet &set) { & (SetFlag::Featured | SetFlag::NotLoaded | SetFlag::Unread - | SetFlag::Special); + | SetFlag::Special + | SetFlag::Installed); _setFlags |= clientFlags; set->flags = _setFlags; set->installDate = _setInstallDate; @@ -1112,15 +1266,15 @@ void StickerSetBox::Inner::applySet(const TLStickerSet &set) { _errors.fire(Error::NotFound); return; } + _loaded = true; _perRow = isEmojiSet() ? kEmojiPerRow : kStickersPerRow; - _rowsCount = (_pack.size() + _perRow - 1) / _perRow; _singleSize = isEmojiSet() ? st::emojiSetSize : st::stickersSize; + _rowsCount = (totalCellsCount() + _perRow - 1) / _perRow; resize( _padding.left() + _perRow * _singleSize.width(), _padding.top() + _rowsCount * _singleSize.height() + _padding.bottom()); - _loaded = true; if (const auto previewId = base::take(_previewDocumentId)) { showPreviewForDocument(previewId); } @@ -1251,6 +1405,10 @@ void StickerSetBox::Inner::mousePressEvent(QMouseEvent *e) { if (e->button() != Qt::LeftButton) { return; } + if (addCellFromGlobalPos(e->globalPos())) { + _addCellPressed = !_dragging.enabled; + return; + } const auto index = stickerFromGlobalPos(e->globalPos()); if (index < 0 || index >= _pack.size()) { return; @@ -1389,6 +1547,7 @@ void StickerSetBox::Inner::showPreviewForDocument(DocumentId documentId) { void StickerSetBox::Inner::leaveEventHook(QEvent *e) { setSelected(-1); + setAddCellHovered(false); } void StickerSetBox::Inner::requestReorder( @@ -1481,6 +1640,13 @@ void StickerSetBox::Inner::mouseReleaseEvent(QMouseEvent *e) { _previewShown = -1; return; } + if (_addCellPressed) { + _addCellPressed = false; + if (addCellFromGlobalPos(e->globalPos())) { + showAddMenu(e->globalPos()); + } + return; + } if (!_previewTimer.isActive()) { return; } @@ -1563,6 +1729,25 @@ void StickerSetBox::Inner::contextMenuEvent(QContextMenuEvent *e) { &st::menuIconCopy); } } + if (!amSetCreator()) { + Api::AddAddToEmojiSetAction( + Ui::Menu::CreateAddActionCallback(_menu.get()), + _show, + _pack[index]); + } else { + const auto addAction = Ui::Menu::CreateAddActionCallback( + _menu.get()); + addAction({ + .text = tr::lng_emoji_context_delete(tr::now), + .handler = [index, this, show = _show] { + show->showBox(Box([=](not_null box) { + fillDeleteStickerBox(box, index); + })); + }, + .icon = &st::menuIconDeleteAttention, + .isAttention = true, + }); + } } else if (details.type != SendMenu::Type::Disabled) { const auto document = _pack[index]; const auto send = crl::guard(this, [=](Api::SendOptions options) { @@ -1594,6 +1779,12 @@ void StickerSetBox::Inner::contextMenuEvent(QContextMenuEvent *e) { (isFaved ? &st::menuIconUnfave : &st::menuIconFave)); + if (!amSetCreator()) { + Api::AddAddToStickerSetAction( + Ui::Menu::CreateAddActionCallback(_menu.get()), + _show, + document); + } if (amSetCreator()) { const auto addAction = Ui::Menu::CreateAddActionCallback( _menu.get()); @@ -1629,6 +1820,8 @@ void StickerSetBox::Inner::fillDeleteStickerBox( const auto document = _pack[index]; const auto weak = base::make_weak(this); const auto show = _show; + const auto type = setType(); + const auto isEmoji = (type == Data::StickersType::Emoji); const auto container = box->verticalLayout(); Ui::AddSkip(container); @@ -1663,7 +1856,9 @@ void StickerSetBox::Inner::fillDeleteStickerBox( }, sticker->lifetime()); const auto label = Ui::CreateChild( line, - tr::lng_stickers_context_delete(), + isEmoji + ? tr::lng_emoji_context_delete() + : tr::lng_stickers_context_delete(), box->getDelegate()->style().title); line->widthValue( ) | rpl::on_next([=](int width) { @@ -1687,7 +1882,9 @@ void StickerSetBox::Inner::fillDeleteStickerBox( box->addRow( object_ptr( container, - tr::lng_stickers_context_delete_sure(), + isEmoji + ? tr::lng_emoji_context_delete_sure() + : tr::lng_stickers_context_delete_sure(), st::boxLabel)); const auto save = [=] { if (state->requestId.current()) { @@ -1702,8 +1899,7 @@ void StickerSetBox::Inner::fillDeleteStickerBox( )).done([=](const TLStickerSet &result) { result.match([&](const MTPDmessages_stickerSet &d) { document->owner().stickers().feedSetFull(d); - document->owner().stickers().notifyUpdated( - Data::StickersType::Stickers); + document->owner().stickers().notifyUpdated(type); }, [](const auto &) { }); if ([[maybe_unused]] const auto strong = weak.get()) { @@ -1746,8 +1942,27 @@ void StickerSetBox::Inner::fillDeleteStickerBox( } void StickerSetBox::Inner::updateSelected() { - auto selected = stickerFromGlobalPos(QCursor::pos()); + const auto cursor = QCursor::pos(); + const auto onAddCell = addCellFromGlobalPos(cursor); + const auto selected = onAddCell + ? -1 + : stickerFromGlobalPos(cursor); setSelected(setType() == Data::StickersType::Masks ? -1 : selected); + setAddCellHovered(onAddCell); +} + +void StickerSetBox::Inner::setAddCellHovered(bool hovered) { + if (_addCellHovered == hovered) { + return; + } + _addCellHovered = hovered; + if (hasAddCell()) { + setCursor((hovered && !_dragging.enabled) + ? style::cur_pointer + : style::cur_default); + const auto rect = addCellRect(); + rtlupdate(rect.x(), rect.y(), rect.width(), rect.height()); + } } void StickerSetBox::Inner::setSelected(int selected) { @@ -1886,6 +2101,10 @@ void StickerSetBox::Inner::paintEvent(QPaintEvent *e) { paintSticker(p, _dragging.index, pos, paused, now); } + if (hasAddCell()) { + paintAddCell(p); + } + if (_lottiePlayer && !paused) { _lottiePlayer->markFrameShown(); } @@ -2312,4 +2531,205 @@ void StickerSetBox::Inner::repaintItems(crl::time now) { update(); } +bool StickerSetBox::Inner::hasAddCell() const { + return _loaded + && _amSetCreator + && (setType() == Data::StickersType::Stickers) + && !_pack.isEmpty() + && (_pack.size() < Api::kStickersInOwnedSetMax); +} + +int StickerSetBox::Inner::totalCellsCount() const { + return _pack.size() + (hasAddCell() ? 1 : 0); +} + +QRect StickerSetBox::Inner::addCellRect() const { + const auto index = _pack.size(); + const auto row = index / _perRow; + const auto column = index % _perRow; + return QRect( + _padding.left() + column * _singleSize.width(), + _padding.top() + row * _singleSize.height(), + _singleSize.width(), + _singleSize.height()); +} + +bool StickerSetBox::Inner::addCellFromGlobalPos(const QPoint &p) const { + if (!hasAddCell()) { + return false; + } + auto local = mapFromGlobal(p); + if (rtl()) { + local.setX(width() - local.x()); + } + const auto rect = addCellRect(); + return rect.contains(local); +} + +void StickerSetBox::Inner::paintAddCell(QPainter &p) const { + const auto ltrRect = addCellRect(); + const auto rect = rtl() + ? QRect( + width() - ltrRect.x() - ltrRect.width(), + ltrRect.y(), + ltrRect.width(), + ltrRect.height()) + : ltrRect; + const auto inner = QRect( + rect::center(rect) - QPoint( + st::stickersAddCellBgRadius, + st::stickersAddCellBgRadius), + Size(st::stickersAddCellBgRadius * 2)); + + auto hq = PainterHighQualityEnabler(p); + const auto base = st::windowSubTextFg->c; + const auto bgAlpha = (_addCellHovered && !_dragging.enabled) + ? 0.22 + : 0.12; + p.setPen(Qt::NoPen); + p.setBrush(anim::with_alpha(base, bgAlpha)); + p.drawEllipse(inner); + + const auto plusHalf = st::stickersAddCellPlusSize / 2; + const auto thickness = st::stickersAddCellPlusThickness; + const auto center = rect.center(); + const auto plusH = QRectF( + center.x() - plusHalf, + center.y() - thickness / 2., + plusHalf * 2, + thickness); + const auto plusV = QRectF( + center.x() - thickness / 2., + center.y() - plusHalf, + thickness, + plusHalf * 2); + const auto radius = thickness / 2.; + p.setBrush(base); + p.drawRoundedRect(plusH, radius, radius); + p.drawRoundedRect(plusV, radius, radius); +} + +void StickerSetBox::Inner::showAddMenu(QPoint globalPos) { + if (_dragging.enabled) { + return; + } + _menu = base::make_unique_q( + this, + st::popupMenuWithIcons); + _menu->addAction( + tr::lng_stickers_create_new(tr::now), + crl::guard(this, [=] { startCreateNewStickerFlow(); }), + &st::menuIconStickerCreate); + _menu->addAction( + tr::lng_stickers_add_existing(tr::now), + crl::guard(this, [=] { startAddExistingStickerFlow(); }), + &st::menuIconStickerAdd); + _menu->popup(globalPos); +} + +void StickerSetBox::Inner::setOuterContainer(QPointer container) { + _outerContainer = std::move(container); +} + +void StickerSetBox::Inner::startAddExistingStickerFlow() { + if (!hasAddCell() || !_outerContainer) { + return; + } + const auto container = _outerContainer.data(); + const auto identifier = StickerSetIdentifier{ + .id = _setId, + .accessHash = _setAccessHash, + .shortName = _setShortName, + }; + const auto session = _session; + const auto show = _show; + + using Selector = ChatHelpers::TabbedSelector; + _pickerPanel = base::make_unique_q( + container, + ChatHelpers::TabbedPanelDescriptor{ + .ownedSelector = object_ptr( + nullptr, + ChatHelpers::TabbedSelectorDescriptor{ + .show = _show, + .st = st::defaultComposeControls.tabbed, + .level = Window::GifPauseReason::Layer, + .mode = Selector::Mode::StickersOnly, + .excludeStickerSetId = _setId, + }), + }); + const auto panel = _pickerPanel.get(); + panel->setDesiredHeightValues( + 1., + st::emojiPanMinHeight / 2, + st::emojiPanMinHeight); + panel->setDropDown(true); + panel->setShowAnimationOrigin(Ui::PanelAnimation::Origin::TopLeft); + panel->hide(); + + panel->selector()->fileChosen( + ) | rpl::on_next([=, this](const ChatHelpers::FileChosen &chosen) { + const auto document = chosen.document; + if (_pickerPanel) { + _pickerPanel->hideAnimated(); + } + const auto emoji = Api::StickerEmojiOrDefault(document); + Api::AddExistingStickerToSet( + session, + identifier, + document, + emoji, + crl::guard(this, [=, this](MTPmessages_StickerSet result) { + applySet(result); + show->showToast( + tr::lng_stickers_create_added(tr::now)); + }), + crl::guard(this, [=](QString err) { + show->showToast(err.isEmpty() + ? tr::lng_attach_failed(tr::now) + : err); + })); + }, panel->lifetime()); + + const auto reposition = [=] { + const auto size = container->size(); + const auto margins = st::emojiPanMargins; + const auto panelWidth = st::emojiPanWidth + + margins.left() + + margins.right(); + const auto panelHeight = st::emojiPanMinHeight + + margins.top() + + margins.bottom(); + const auto top = std::max(0, (size.height() - panelHeight) / 2); + const auto right = (size.width() + panelWidth) / 2; + panel->moveTopRight(top, right); + }; + base::install_event_filter(panel, container, [=]( + not_null event) { + const auto type = event->type(); + if (type == QEvent::Move || type == QEvent::Resize) { + crl::on_main(panel, reposition); + } + return base::EventFilterResult::Continue; + }); + reposition(); + panel->showAnimated(); +} + +void StickerSetBox::Inner::startCreateNewStickerFlow() { + if (!hasAddCell()) { + return; + } + const auto identifier = StickerSetIdentifier{ + .id = _setId, + .accessHash = _setAccessHash, + .shortName = _setShortName, + }; + const auto onDone = crl::guard(this, [=, this]( + MTPmessages_StickerSet result) { + applySet(result); + }); + Api::OpenCreateStickerFlow(_show, identifier, onDone); +} + StickerSetBox::Inner::~Inner() = default; diff --git a/Telegram/SourceFiles/boxes/stickers_box.cpp b/Telegram/SourceFiles/boxes/stickers_box.cpp index ed4a7d02c7..feaf023bab 100644 --- a/Telegram/SourceFiles/boxes/stickers_box.cpp +++ b/Telegram/SourceFiles/boxes/stickers_box.cpp @@ -414,8 +414,7 @@ StickersBox::StickersBox( std::shared_ptr show, Section section, bool masks) -: _st(st::stickersRowItem) -, _show(std::move(show)) +: _show(std::move(show)) , _session(&_show->session()) , _api(&_session->mtp()) , _tabs(this, st::stickersTabs) @@ -437,8 +436,7 @@ StickersBox::StickersBox( std::shared_ptr show, not_null megagroup, bool isEmoji) -: _st(st::stickersRowItem) -, _show(std::move(show)) +: _show(std::move(show)) , _session(&_show->session()) , _api(&_session->mtp()) , _section(Section::Installed) @@ -456,8 +454,7 @@ StickersBox::StickersBox( QWidget*, std::shared_ptr show, const QVector &attachedSets) -: _st(st::stickersRowItem) -, _show(std::move(show)) +: _show(std::move(show)) , _session(&_show->session()) , _api(&_session->mtp()) , _section(Section::Attached) @@ -472,8 +469,7 @@ StickersBox::StickersBox( QWidget*, std::shared_ptr show, const std::vector &emojiSets) -: _st(st::stickersRowItem) -, _show(std::move(show)) +: _show(std::move(show)) , _session(&_show->session()) , _api(&_session->mtp()) , _section(Section::Attached) diff --git a/Telegram/SourceFiles/boxes/stickers_box.h b/Telegram/SourceFiles/boxes/stickers_box.h index 89b31ccf51..a6a83da93a 100644 --- a/Telegram/SourceFiles/boxes/stickers_box.h +++ b/Telegram/SourceFiles/boxes/stickers_box.h @@ -143,7 +143,6 @@ private: std::array widgets() const; - const style::PeerListItem &_st; const std::shared_ptr _show; const not_null _session; MTP::Sender _api; diff --git a/Telegram/SourceFiles/boxes/url_auth_box.cpp b/Telegram/SourceFiles/boxes/url_auth_box.cpp index 51735b1cdb..13649dca8c 100644 --- a/Telegram/SourceFiles/boxes/url_auth_box.cpp +++ b/Telegram/SourceFiles/boxes/url_auth_box.cpp @@ -409,7 +409,7 @@ void RequestButton( url, displayName, session->user()->name(), - bot->firstName, + bot ? bot->firstName : QString(), callback), Ui::LayerOption::KeepOther); } diff --git a/Telegram/SourceFiles/calls/calls.style b/Telegram/SourceFiles/calls/calls.style index ebcaa85619..b4ba3d17fe 100644 --- a/Telegram/SourceFiles/calls/calls.style +++ b/Telegram/SourceFiles/calls/calls.style @@ -52,7 +52,7 @@ callBodyLayout: CallBodyLayout { participantsTop: 294px; muteStroke: 3px; muteSize: 36px; - mutePosition: point(142px, 135px); + mutePosition: point(140px, 135px); } callBodyWithPreview: CallBodyLayout { height: 185px; @@ -631,7 +631,6 @@ groupCallMenuAbout: FlatLabel(defaultFlatLabel) { textFg: groupCallMemberNotJoinedStatus; palette: groupCallTextPalette; minWidth: 200px; - maxHeight: 92px; } callDeviceSelectionLabel: FlatLabel(defaultSubsectionTitle) { textFg: groupCallActiveFg; diff --git a/Telegram/SourceFiles/calls/calls_call.cpp b/Telegram/SourceFiles/calls/calls_call.cpp index 41fb16434c..74327a4925 100644 --- a/Telegram/SourceFiles/calls/calls_call.cpp +++ b/Telegram/SourceFiles/calls/calls_call.cpp @@ -1196,6 +1196,10 @@ void Call::createAndStartController(const MTPDphoneCall &call) { raw->setIncomingVideoOutput(_videoIncoming->sink()); raw->setAudioOutputDuckingEnabled(settings.callAudioDuckingEnabled()); + _muted.value() | rpl::on_next([=](bool muted) { + Core::App().mediaDevices().setCaptureMuted(muted); + }, _instanceLifetime); + _state.value() | rpl::on_next([=](State state) { const auto track = (state != State::FailedHangingUp) && (state != State::Failed) @@ -1207,10 +1211,6 @@ void Call::createAndStartController(const MTPDphoneCall &call) { Core::App().mediaDevices().setCaptureMuteTracker(this, track); }, _instanceLifetime); - _muted.value() | rpl::on_next([=](bool muted) { - Core::App().mediaDevices().setCaptureMuted(muted); - }, _instanceLifetime); - #if 0 Core::App().batterySaving().value( ) | rpl::on_next([=](bool isSaving) { diff --git a/Telegram/SourceFiles/calls/calls_top_bar.h b/Telegram/SourceFiles/calls/calls_top_bar.h index 283c647cd4..fbc6438a05 100644 --- a/Telegram/SourceFiles/calls/calls_top_bar.h +++ b/Telegram/SourceFiles/calls/calls_top_bar.h @@ -101,7 +101,6 @@ private: rpl::variable _isGroupConnecting = false; std::vector> _conferenceFirstUsers; - int _conferenceUsersCount = 0; QBrush _groupBrush; anim::linear_gradients _gradients; diff --git a/Telegram/SourceFiles/calls/group/calls_cover_item.cpp b/Telegram/SourceFiles/calls/group/calls_cover_item.cpp index 4d8ddbb9ab..8f8a44a292 100644 --- a/Telegram/SourceFiles/calls/group/calls_cover_item.cpp +++ b/Telegram/SourceFiles/calls/group/calls_cover_item.cpp @@ -66,13 +66,28 @@ AboutItem::AboutItem( , _dummyAction(new QAction(parent)) { setPointerCursor(false); + _text->setSelectable(true); + + const auto added = st.itemPadding.left() + st.itemPadding.right(); + + sizeValue( + ) | rpl::on_next([=](const QSize &s) { + if (s.width() <= added) { + return; + } + _text->resizeToWidth(s.width() - added); + _text->moveToLeft(st.itemPadding.left(), st.itemPadding.top()); + }, lifetime()); + + _text->heightValue( + ) | rpl::on_next([=] { + resize(width(), contentHeight()); + }, lifetime()); + + _text->resizeToWidth(parent->width() - added); fitToMenuWidth(); enableMouseSelecting(); enableMouseSelecting(_text.get()); - - _text->setSelectable(true); - _text->resizeToWidth(st::groupCallMenuAbout.minWidth); - _text->moveToLeft(st.itemPadding.left(), st.itemPadding.top()); } not_null AboutItem::action() const { diff --git a/Telegram/SourceFiles/calls/group/calls_group_call.cpp b/Telegram/SourceFiles/calls/group/calls_group_call.cpp index f002f3cd22..70be0e306a 100644 --- a/Telegram/SourceFiles/calls/group/calls_group_call.cpp +++ b/Telegram/SourceFiles/calls/group/calls_group_call.cpp @@ -1606,6 +1606,7 @@ void GroupCall::rejoin(not_null as) { _joinState.action = JoinAction::Joining; _joinState.ssrc = 0; _initialMuteStateSent = false; + _systemMuteReconciled = false; setState(State::Joining); if (!tryCreateController()) { setInstanceMode(InstanceMode::None); @@ -1794,6 +1795,13 @@ void GroupCall::joinDone( : State::Joined); applyMeInCallLocally(); maybeSendMutedUpdate(wasMuteState); + _systemMuteReconciled = true; + if (!_rtmp) { + const auto state = muted(); + const auto nowMuted = (state != MuteState::Active) + && (state != MuteState::PushToTalk); + Core::App().mediaDevices().setCaptureMuted(nowMuted); + } for (auto &state : _subchains) { // Accept initial join blocks. @@ -2699,8 +2707,8 @@ void GroupCall::setupMediaDevices() { const auto muted = (state != MuteState::Active) && (state != MuteState::PushToTalk); const auto track = !muted || (state == MuteState::Muted); - devices->setCaptureMuteTracker(this, track); devices->setCaptureMuted(muted); + devices->setCaptureMuteTracker(this, track); }, _lifetime); } } @@ -2712,8 +2720,20 @@ void GroupCall::captureMuteChanged(bool mute) { || oldState == MuteState::RaisedHand || oldState == MuteState::Muted)) { return; + } else if (!mute + && (oldState == MuteState::ForceMuted + || oldState == MuteState::RaisedHand)) { + crl::on_main(this, [] { + Core::App().mediaDevices().setCaptureMuted(true); + }); + return; } else if (!mute && oldState != MuteState::Muted) { return; + } else if (!mute && !_systemMuteReconciled) { + crl::on_main(this, [] { + Core::App().mediaDevices().setCaptureMuted(true); + }); + return; } setMutedAndUpdate(mute ? MuteState::Muted : MuteState::Active); } diff --git a/Telegram/SourceFiles/calls/group/calls_group_call.h b/Telegram/SourceFiles/calls/group/calls_group_call.h index c2820cd8b7..4e63e93482 100644 --- a/Telegram/SourceFiles/calls/group/calls_group_call.h +++ b/Telegram/SourceFiles/calls/group/calls_group_call.h @@ -727,6 +727,7 @@ private: rpl::variable _emptyRtmp = false; rpl::variable _messagesEnabled = false; bool _initialMuteStateSent = false; + bool _systemMuteReconciled = false; bool _acceptFields = false; rpl::event_stream _otherParticipantStateValue; diff --git a/Telegram/SourceFiles/calls/group/calls_group_rtmp.cpp b/Telegram/SourceFiles/calls/group/calls_group_rtmp.cpp index 7fd579b465..e398598a80 100644 --- a/Telegram/SourceFiles/calls/group/calls_group_rtmp.cpp +++ b/Telegram/SourceFiles/calls/group/calls_group_rtmp.cpp @@ -37,8 +37,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Calls::Group { namespace { -constexpr auto kPasswordCharAmount = 24; - void StartWithBox( not_null box, Fn done, diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index e14e45073c..059365b5b7 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -425,6 +425,58 @@ stickersScroll: ScrollArea(boxScroll) { stickersRowDisabledOpacity: 0.4; stickersRowDuration: 200; +stickersAddCellPlusSize: 22px; +stickersAddCellPlusThickness: 2px; +stickersAddCellBgRadius: 28px; + +stickersEmojiPickerExpandedRadius: 20px; +stickersEmojiPickerBg: emojiPanBg; +stickersEmojiPickerShadow: windowShadowFg; +stickersEmojiPickerPadding: margins(12px, 8px, 12px, 0px); +stickersEmojiPickerItemSize: 30px; +stickersEmojiPickerItemSkip: 4px; +stickersEmojiPickerStripHeight: 40px; +stickersEmojiPickerExpandedHeight: 220px; +stickersEmojiPickerStripBubble: icon{ + { "chat/reactions_bubble_shadow", windowShadowFg }, + { "chat/reactions_bubble", emojiPanBg }, +}; +stickersEmojiPickerStripBubbleRight: 20px; +stickersEmojiPickerSelectedBg: windowBgActive; +stickersEmojiPickerSelectedFg: windowBgActive; +stickersEmojiPickerHeaderFg: windowSubTextFg; +stickersEmojiPickerScroll: ScrollArea(boxScroll) { + width: 14px; + deltax: 5px; + deltat: 4px; + deltab: 18px; +} +stickersEmojiPickerAbout: FlatLabel(defaultFlatLabel) { + minWidth: 100px; + align: align(top); + textFg: windowSubTextFg; + style: TextStyle(defaultTextStyle) { + font: font(12px); + } +} +stickersEmojiPickerSectionHeader: FlatLabel(defaultFlatLabel) { + minWidth: 10px; + align: align(topleft); + textFg: windowSubTextFg; + style: TextStyle(defaultTextStyle) { + font: font(12px semibold); + } +} +stickersEmojiPickerExpandIcon: icon {{ "intro_country_dropdown", windowSubTextFg }}; +stickersEmojiPickerCollapseIcon: icon {{ "intro_country_dropdown-flip_vertical", windowSubTextFg }}; +stickersEmojiPickerExpandSize: 24px; +stickersEmojiPickerExpandBg: windowBgRipple; +stickersEmojiPickerBoxShadow: BoxShadow { + blurRadius: 20px; + offset: point(0px, 6px); + opacity: 0.22; +} + emojiStatusDefault: icon {{ "emoji/stickers_premium", emojiIconFg }}; filtersRemove: IconButton(stickersRemove) { @@ -610,7 +662,7 @@ sendBoxAlbumSmallGroupSize: size(30px, 25px); sendBoxAlbumSmallGroupCircleSize: 27px; sendBoxFileGroupSkipTop: 2px; -sendBoxFileGroupSkipRight: 5px; +sendBoxFileGroupSkipRight: 1px; sendBoxFileGroupEditInternalSkip: -1px; sendBoxAlbumGroupButtonFile: IconButton(editMediaButton) { @@ -623,7 +675,7 @@ sendBoxAlbumGroupDeleteButtonIconFile: icon {{ "send_media/send_media_cross", me sendBoxAlbumButtonMediaMore: icon {{ "send_media/send_media_more", roundedFg }}; sendBoxAlbumGroupButtonMediaMore: icon {{ "send_media/send_media_more", roundedFg, point(4px, 1px) }}; -sendBoxAlbumGroupButtonMediaDelete: icon {{ "send_media/send_media_cross", roundedFg, point(-2px, 1px) }}; +sendBoxAlbumGroupButtonMediaDelete: icon {{ "send_media/send_media_cross", roundedFg }}; defaultComposeIcons: ComposeIcons { settings: icon {{ "emoji/emoji_settings", emojiIconFg }}; @@ -1035,6 +1087,19 @@ historyPinnedShowAll: IconButton(historyReplyCancel) { icon: icon {{ "pinned_show_all", historyReplyCancelFg }}; iconOver: icon {{ "pinned_show_all", historyReplyCancelFgOver }}; } +sendFilesReplyIconPosition: point(11px, 7px); +sendFilesReplyCancelSize: 24px; +sendFilesReplyCancel: IconButton(editMediaButton) { + width: sendFilesReplyCancelSize; + height: sendFilesReplyCancelSize; + icon: icon {{ "send_media/send_media_cross", historyReplyCancelFg }}; + iconOver: icon {{ "send_media/send_media_cross", historyReplyCancelFgOver }}; + ripple: RippleAnimation(defaultRippleAnimation) { + color: windowBgRipple; + } + + rippleAreaSize: sendFilesReplyCancelSize; +} historyPinnedBotButton: RoundButton(defaultActiveButton) { width: -34px; height: 30px; @@ -1445,6 +1510,10 @@ historyAiComposeButtonLetters: icon{{ "chat/ai_letters-20x20", historyComposeIco historyAiComposeButtonStar1: icon{{ "chat/ai_star1-20x20", historyComposeIconFg }}; historyAiComposeButtonStar2: icon{{ "chat/ai_star2-20x20", historyComposeIconFg }}; historyAiComposeButtonPosition: point(-8px, -4px); +historySendAsFileButton: IconButton(historyAiComposeButton) { + icon: icon {{ "chat/text_to_file-18x18", historyComposeIconFg }}; + iconOver: icon {{ "chat/text_to_file-18x18", historyComposeIconFgOver }}; +} historyAiComposeTooltipSkip: 8px; importantTooltipHide: IconButton(defaultIconButton) { width: 34px; diff --git a/Telegram/SourceFiles/chat_helpers/compose/compose_show.cpp b/Telegram/SourceFiles/chat_helpers/compose/compose_show.cpp index 32a2eff1f8..872f4ef7af 100644 --- a/Telegram/SourceFiles/chat_helpers/compose/compose_show.cpp +++ b/Telegram/SourceFiles/chat_helpers/compose/compose_show.cpp @@ -37,6 +37,8 @@ ResolveWindow ResolveWindowDefault() { return b; } else if (const auto c = check(app.windowFor(account))) { return c; + } else if (Core::Quitting()) { + return nullptr; } else if (const auto d = check(app.ensureSeparateWindowFor( account))) { return d; diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp index d2e3d54682..b4f58b8d8d 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.cpp @@ -68,8 +68,10 @@ namespace { constexpr auto kCollapsedRows = 3; constexpr auto kAppearDuration = 0.3; constexpr auto kCustomSearchLimit = 256; +constexpr auto kCloudSearchPageLimit = 50; constexpr auto kColorPickerDelay = crl::time(500); constexpr auto kSearchRequestDelay = 400; +constexpr auto kPreloadSearchPages = 4; using Core::RecentEmojiId; using Core::RecentEmojiDocument; @@ -672,12 +674,13 @@ void EmojiListWidget::applyNextSearchQuery() { } const auto guard = gsl::finally([&] { finish(); }); auto plain = collectPlainSearchResults(); - if (_searchEmoji == _searchEmojiPrevious) { - return; - } _searchEmoticon = QString(); - for (const auto emoji : plain) { - _searchEmoticon += emoji->text(); + { + auto exactSet = base::flat_set(); + const auto exact = SearchEmoji(_searchQuery, exactSet, true); + for (const auto emoji : exact) { + _searchEmoticon += emoji->text(); + } } _searchResults.clear(); _searchCustomIds.clear(); @@ -692,9 +695,6 @@ void EmojiListWidget::applyNextSearchQuery() { if (_mode != Mode::Full || session().premium()) { appendPremiumSearchResults(); } - if (_mode == Mode::Full) { - appendLocalPackSearchResults(); - } _searchQueryText = ranges::accumulate( _searchQuery, @@ -711,13 +711,13 @@ void EmojiListWidget::applyNextSearchQuery() { _api.request(requestId).cancel(); } _searchNextRequestQuery = _searchQueryText; - const auto cloudCached = _searchCloudCache.find(_searchQueryText) + _searchRequestQuery = _searchQueryText; + const auto cloudCached = _searchCloudCache.find(_searchRequestQuery) != _searchCloudCache.cend(); - const auto setsCached = _searchSetsCache.find(_searchQueryText) + const auto setsCached = _searchSetsCache.find(_searchRequestQuery) != _searchSetsCache.cend(); if (cloudCached || setsCached) { _searchRequestTimer.cancel(); - _searchRequestQuery = _searchQueryText; fillCloudSearchResults(); fillCloudSearchSets(); if (!cloudCached || !setsCached) { @@ -824,62 +824,6 @@ void EmojiListWidget::appendPremiumSearchResults() { } } -void EmojiListWidget::appendLocalPackSearchResults() { - const auto text = _searchQueryText.toLower(); - if (text.isEmpty()) { - return; - } - const auto test = session().isTestMode(); - const auto &sets = session().data().stickers().sets(); - const auto processSet = [&](uint64 setId) { - const auto it = sets.find(setId); - if (it == sets.end()) { - return; - } - const auto set = it->second.get(); - if (!(set->flags & Data::StickersSetFlag::Emoji)) { - return; - } - const auto title = set->title.toLower(); - if (!title.startsWith(text) - && !title.contains(u' ' + text)) { - return; - } - const auto &list = set->stickers.empty() - ? set->covers - : set->stickers; - for (const auto document : list) { - if (_searchResults.size() >= kCustomSearchLimit) { - return; - } - const auto sticker = document->sticker(); - if (!sticker) { - continue; - } - const auto id = document->id; - if (!_searchCustomIds.emplace(id).second) { - continue; - } - const auto statusId = EmojiStatusId{ id }; - _searchResults.push_back({ - .custom = resolveCustomEmoji( - statusId, - document, - SearchEmojiSectionSetId()), - .id = { RecentEmojiDocument{ .id = id, .test = test } }, - }); - } - }; - for (const auto setId - : session().data().stickers().emojiSetsOrder()) { - processSet(setId); - } - for (const auto setId - : session().data().stickers().featuredEmojiSetsOrder()) { - processSet(setId); - } -} - void EmojiListWidget::toggleSearchLoading(bool loading) { if (_search) { _search->setLoading(loading); @@ -891,74 +835,76 @@ void EmojiListWidget::toggleSearchLoading(bool loading) { } void EmojiListWidget::sendSearchRequest() { - _searchRequestQuery = _searchQueryText; + _searchRequestQuery = _searchNextRequestQuery; if (_searchRequestQuery.isEmpty()) { return; } + const auto query = _searchRequestQuery; const auto cloudCached = _searchCloudCache.find( - _searchRequestQuery) != _searchCloudCache.cend(); + query) != _searchCloudCache.cend(); const auto setsCached = _searchSetsCache.find( - _searchRequestQuery) != _searchSetsCache.cend(); + query) != _searchSetsCache.cend(); if (cloudCached && setsCached) { toggleSearchLoading(false); return; } toggleSearchLoading(true); - const auto hash = uint64(0); if (!cloudCached) { - auto langCodes = QVector(); - const auto method = QGuiApplication::inputMethod(); - if (method) { - for (const auto &lang : method->locale().uiLanguages()) { - langCodes.push_back(MTP_string(lang)); - } - } - using Flag = MTPmessages_SearchStickers::Flag; - _searchCloudRequestId = _api.request(MTPmessages_SearchStickers( - MTP_flags(Flag::f_emojis), - MTP_string(_searchRequestQuery), - MTP_string(_searchEmoticon), - MTP_vector(langCodes), - MTP_int(0), - MTP_int(50), - MTP_long(hash) - )).done([=](const MTPmessages_FoundStickers &result) { - searchCloudResultsDone(result); - }).fail([=] { - _searchCloudRequestId = 0; - _searchCloudCache.emplace( - _searchRequestQuery, - std::vector()); - if (!_searchSetsRequestId) { - toggleSearchLoading(false); - showSearchResults(); - } - }).handleAllErrors().send(); + requestSearchCloud(query, 0, true); } if (!setsCached) { - sendSearchSetsRequest(); + sendSearchSetsRequest(query); } } -void EmojiListWidget::sendSearchSetsRequest() { +void EmojiListWidget::sendSearchSetsRequest(const QString &query) { const auto hash = uint64(0); _searchSetsRequestId = _api.request( MTPmessages_SearchEmojiStickerSets( MTP_flags(0), - MTP_string(_searchRequestQuery), + MTP_string(query), MTP_long(hash)) ).done([=](const MTPmessages_FoundStickerSets &result) { - searchSetsResultsDone(result); + searchSetsResultsDone(query, result); }).fail([=] { _searchSetsRequestId = 0; - if (!_searchCloudRequestId) { + if ((_searchRequestQuery == query) && !_searchCloudRequestId) { toggleSearchLoading(false); } }).handleAllErrors().send(); } +void EmojiListWidget::requestSearchCloud( + const QString &query, + int offset, + bool fallbackToEmpty) { + using Flag = MTPmessages_SearchStickers::Flag; + const auto hash = uint64(0); + _searchCloudRequestId = _api.request(MTPmessages_SearchStickers( + MTP_flags(Flag::f_emojis), + MTP_string(query), + MTP_string(_searchEmoticon), + MTP_vector(SearchStickersLangCodes()), + MTP_int(offset), + MTP_int(kCloudSearchPageLimit), + MTP_long(hash) + )).done([=](const MTPmessages_FoundStickers &result) { + searchCloudResultsDone(query, offset, result); + }).fail([=] { + _searchCloudRequestId = 0; + if (!fallbackToEmpty) { + return; + } + _searchCloudCache.emplace(query, std::vector()); + if ((_searchRequestQuery == query) && !_searchSetsRequestId) { + toggleSearchLoading(false); + showSearchResults(); + } + }).handleAllErrors().send(); +} + void EmojiListWidget::cancelSearchRequest() { toggleSearchLoading(false); if (const auto requestId = base::take(_searchCloudRequestId)) { @@ -971,21 +917,48 @@ void EmojiListWidget::cancelSearchRequest() { _searchRequestQuery = QString(); _searchNextRequestQuery = QString(); _searchCloudCache.clear(); + _searchCloudNextOffset.clear(); _searchSetsCache.clear(); _searchSets.clear(); } void EmojiListWidget::searchCloudResultsDone( + const QString &query, + int requestedOffset, const MTPmessages_FoundStickers &result) { _searchCloudRequestId = 0; + const auto active = (_searchRequestQuery == query); - result.match([&](const MTPDmessages_foundStickersNotModified &) { + result.match([&](const MTPDmessages_foundStickersNotModified &data) { LOG(("API: messages.foundStickersNotModified.")); - }, [&](const MTPDmessages_foundStickers &data) { - auto it = _searchCloudCache.find(_searchRequestQuery); + auto it = _searchCloudCache.find(query); if (it == _searchCloudCache.cend()) { it = _searchCloudCache.emplace( - _searchRequestQuery, + query, + std::vector()).first; + } + if (const auto next = data.vnext_offset()) { + if (next->v > requestedOffset) { + _searchCloudNextOffset[query] = next->v; + } else { + _searchCloudNextOffset.erase(query); + } + } else { + _searchCloudNextOffset.erase(query); + } + if (!active) { + return; + } + if (!_searchSetsRequestId) { + toggleSearchLoading(false); + } + showSearchResults(); + checkPaginateSearchCloud(getVisibleTop(), getVisibleBottom()); + }, [&](const MTPDmessages_foundStickers &data) { + auto it = _searchCloudCache.find(query); + if (it == _searchCloudCache.cend()) { + it = _searchCloudCache.emplace( + query, std::vector()).first; } @@ -996,17 +969,67 @@ void EmojiListWidget::searchCloudResultsDone( } } + if (const auto next = data.vnext_offset()) { + if (next->v > requestedOffset) { + _searchCloudNextOffset[query] = next->v; + } else { + _searchCloudNextOffset.erase(query); + } + } else { + _searchCloudNextOffset.erase(query); + } + + if (!active) { + return; + } + if (!_searchSetsRequestId) { toggleSearchLoading(false); } showSearchResults(); + checkPaginateSearchCloud( + getVisibleTop(), + getVisibleBottom()); }); } +void EmojiListWidget::loadMoreSearchCloud() { + if (_searchCloudRequestId + || _searchRequestQuery.isEmpty() + || (_searchRequestQuery != _searchNextRequestQuery)) { + return; + } + const auto query = _searchRequestQuery; + const auto offsetIt = _searchCloudNextOffset.find(query); + if (offsetIt == _searchCloudNextOffset.end()) { + return; + } + requestSearchCloud(query, offsetIt->second, false); +} + +void EmojiListWidget::checkPaginateSearchCloud( + int visibleTop, + int visibleBottom) { + if (!_searchMode + || _searchRequestQuery.isEmpty() + || (_searchRequestQuery != _searchNextRequestQuery) + || _searchCloudRequestId) { + return; + } + const auto visibleHeight = visibleBottom - visibleTop; + if (visibleHeight <= 0) { + return; + } + if (visibleBottom > height() - visibleHeight * kPreloadSearchPages) { + loadMoreSearchCloud(); + } +} + void EmojiListWidget::searchSetsResultsDone( + const QString &query, const MTPmessages_FoundStickerSets &result) { _searchSetsRequestId = 0; - if (!_searchCloudRequestId) { + if ((_searchRequestQuery == query) && !_searchCloudRequestId) { toggleSearchLoading(false); } @@ -1014,10 +1037,10 @@ void EmojiListWidget::searchSetsResultsDone( LOG(("API Error: " "messages.foundStickerSetsNotModified not expected.")); }, [&](const MTPDmessages_foundStickerSets &data) { - auto it = _searchSetsCache.find(_searchRequestQuery); + auto it = _searchSetsCache.find(query); if (it == _searchSetsCache.cend()) { it = _searchSetsCache.emplace( - _searchRequestQuery, + query, std::vector()).first; } for (const auto &setData : data.vsets().v) { @@ -1028,7 +1051,9 @@ void EmojiListWidget::searchSetsResultsDone( } it->second.push_back(set->id); } - showSearchResults(); + if (_searchRequestQuery == query) { + showSearchResults(); + } }); } @@ -1052,9 +1077,6 @@ void EmojiListWidget::showSearchResults() { appendPremiumSearchResults(); } fillCloudSearchResults(); - if (_mode == Mode::Full) { - appendLocalPackSearchResults(); - } fillCloudSearchSets(); resizeToWidth(width()); @@ -1070,9 +1092,6 @@ void EmojiListWidget::fillCloudSearchResults() { } const auto test = session().isTestMode(); for (const auto id : it->second) { - if (_searchResults.size() >= kCustomSearchLimit) { - break; - } if (!_searchCustomIds.emplace(id).second) { continue; } @@ -1268,6 +1287,7 @@ void EmojiListWidget::visibleTopBottomUpdated( ValidateIconAnimations::Full); } unloadNotSeenCustom(visibleTop, visibleBottom); + checkPaginateSearchCloud(visibleTop, visibleBottom); } void EmojiListWidget::unloadNotSeenCustom( @@ -1831,6 +1851,9 @@ void EmojiListWidget::paint( && !_searchRequestTimer.isActive()) { paintEmptySearchResults(p); } + const auto badgeText = tr::lng_stickers_creator_badge(tr::now); + const auto &badgeFont = st::stickersHeaderBadgeFont; + const auto badgeWidth = badgeFont->width(badgeText); enumerateSections([&](const SectionInfo &info) { if (clip.top() >= info.rowsBottom) { return true; @@ -1843,7 +1866,7 @@ void EmojiListWidget::paint( const auto titleLeft = (info.premiumRequired ? st().headerLockedLeft : st().headerLeft) - st().margin.left(); - const auto widthForTitle = emojiRight() + auto widthForTitle = emojiRight() - titleLeft - paintButtonGetWidth(p, info, buttonSelected, clip); if (info.section > 0 && clip.top() < info.rowsTop) { @@ -1854,6 +1877,18 @@ void EmojiListWidget::paint( : (info.section < _staticCount) ? ChatHelpers::EmojiCategoryTitle(info.section)(tr::now) : _custom[info.section - _staticCount].title; + const auto titleSet = (_searchMode && info.section > 0) + ? searchSetBySection(info.section).set.get() + : (info.section >= _staticCount) + ? _custom[info.section - _staticCount].set.get() + : nullptr; + const auto amCreator = titleSet + && (titleSet->flags & Data::StickersSetFlag::AmCreator); + if (amCreator) { + widthForTitle -= badgeWidth + + st::stickersFeaturedUnreadSkip + + st::stickersHeaderBadgeFontSkip; + } auto titleWidth = st::emojiPanHeaderFont->width(titleText); if (titleWidth > widthForTitle) { titleText = st::emojiPanHeaderFont->elided(titleText, widthForTitle); @@ -1871,6 +1906,38 @@ void EmojiListWidget::paint( p.setFont(st::emojiPanHeaderFont); p.setPen(st().headerFg); p.drawText(titleLeft, textBaseline, titleText); + if (amCreator) { + const auto badgeLeft = titleLeft + + titleWidth + + st::stickersFeaturedUnreadSkip; + { + auto color = st().headerFg->c; + color.setAlphaF(st().headerFg->c.alphaF() * 0.15); + p.setPen(Qt::NoPen); + p.setBrush(color); + auto hq = PainterHighQualityEnabler(p); + p.drawRoundedRect( + style::rtlrect( + badgeLeft, + info.top + st::stickersHeaderBadgeFontTop, + badgeWidth + badgeFont->height, + badgeFont->height, + width()), + badgeFont->height / 2., + badgeFont->height / 2.); + } + p.setPen(st().headerFg); + p.setBrush(Qt::NoBrush); + p.setFont(badgeFont); + p.drawText( + QRect( + badgeLeft + badgeFont->height / 2, + info.top + st::stickersHeaderBadgeFontTop, + badgeWidth, + badgeFont->height), + badgeText, + style::al_center); + } } if (clip.top() + clip.height() > info.rowsTop) { ensureLoaded(info.section); @@ -2873,6 +2940,7 @@ void EmojiListWidget::refreshCustom() { return; } else if (valid) { i->thumbnailDocument = it->second->lookupThumbnailDocument(); + i->title = it->second->title; const auto premiumRequired = premium && premiumMayBeBought; if (i->canRemove != canRemove || i->premiumRequired != premiumRequired) { diff --git a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h index c8deb74bb7..afa38c9fbe 100644 --- a/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/emoji_list_widget.h @@ -299,13 +299,23 @@ private: void setupSearch(); [[nodiscard]] std::vector collectPlainSearchResults(); void appendPremiumSearchResults(); - void appendLocalPackSearchResults(); void sendSearchRequest(); - void sendSearchSetsRequest(); + void sendSearchSetsRequest(const QString &query); + void requestSearchCloud( + const QString &query, + int offset, + bool fallbackToEmpty); void cancelSearchRequest(); void toggleSearchLoading(bool loading); - void searchCloudResultsDone(const MTPmessages_FoundStickers &result); - void searchSetsResultsDone(const MTPmessages_FoundStickerSets &result); + void searchCloudResultsDone( + const QString &query, + int requestedOffset, + const MTPmessages_FoundStickers &result); + void loadMoreSearchCloud(); + void checkPaginateSearchCloud(int visibleTop, int visibleBottom); + void searchSetsResultsDone( + const QString &query, + const MTPmessages_FoundStickerSets &result); void showSearchResults(); void fillCloudSearchResults(); void fillCloudSearchSets(); @@ -484,6 +494,7 @@ private: std::vector _searchResults; bool _searchMode = false; std::map> _searchCloudCache; + std::map _searchCloudNextOffset; std::map> _searchSetsCache; std::vector _searchSets; QString _searchRequestQuery; diff --git a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp new file mode 100644 index 0000000000..384bd3c41f --- /dev/null +++ b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.cpp @@ -0,0 +1,692 @@ +/* +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 "chat_helpers/emoji_picker_overlay.h" + +#include "ui/abstract_button.h" +#include "ui/emoji_config.h" +#include "ui/painter.h" +#include "ui/widgets/labels.h" +#include "ui/widgets/scroll_area.h" +#include "styles/style_chat_helpers.h" + +#include +#include +#include + +namespace ChatHelpers { +namespace { + +[[nodiscard]] std::vector BuildAllEmojis() { + using Section = Ui::Emoji::Section; + auto result = std::vector(); + for (auto i = int(Section::People); i <= int(Section::Symbols); ++i) { + const auto section = Ui::Emoji::GetSection(Section(i)); + result.reserve(result.size() + section.size()); + for (const auto emoji : section) { + result.push_back(emoji); + } + } + return result; +} + +[[nodiscard]] std::vector DefaultRecentVector() { + const auto src = Ui::Emoji::GetDefaultRecent(); + return std::vector(src.begin(), src.end()); +} + +} // namespace + +class EmojiPickerOverlay::Strip final : public Ui::RpWidget { +public: + Strip(QWidget *parent, not_null owner); + + void refresh(); + +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; + +private: + struct Cell { + EmojiPtr emoji = nullptr; + QRect rect; + }; + + [[nodiscard]] int cellAtPoint(QPoint p) const; + void updateHover(int index); + + const not_null _owner; + std::vector _cells; + int _hover = -1; + int _pressed = -1; + +}; + +class EmojiPickerOverlay::Grid final : public Ui::RpWidget { +public: + Grid(QWidget *parent, not_null owner); + + void setEmojis(std::vector emojis); + int resizeGetHeight(int newWidth) override; + void refresh(); + +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; + +private: + struct Cell { + EmojiPtr emoji = nullptr; + QRect rect; + }; + + [[nodiscard]] int cellAtPoint(QPoint p) const; + void relayoutCells(); + void updateHover(int index); + + const not_null _owner; + std::vector _emojis; + std::vector _cells; + int _columns = 0; + int _hover = -1; + int _pressed = -1; + +}; + +namespace { + +void DrawEmojiCell( + QPainter &p, + const QRect &cell, + EmojiPtr emoji, + bool selected, + bool hovered) { + if (selected) { + p.setPen(Qt::NoPen); + p.setBrush(st::stickersEmojiPickerSelectedBg); + p.drawEllipse(cell); + } else if (hovered) { + p.setPen(Qt::NoPen); + p.setBrush(anim::with_alpha(st::windowSubTextFg->c, 0.12)); + p.drawEllipse(cell); + } + if (!emoji) { + return; + } + const auto esize = Ui::Emoji::GetSizeLarge(); + const auto dpr = style::DevicePixelRatio(); + const auto pixelSize = esize / dpr; + const auto drawSize = std::min( + pixelSize, + st::stickersEmojiPickerItemSize - 4); + const auto x = cell.x() + (cell.width() - drawSize) / 2; + const auto y = cell.y() + (cell.height() - drawSize) / 2; + if (drawSize == pixelSize) { + Ui::Emoji::Draw(p, emoji, esize, x, y); + } else { + const auto target = QRect(x, y, drawSize, drawSize); + auto buffer = QImage( + QSize(pixelSize, pixelSize) * dpr, + QImage::Format_ARGB32_Premultiplied); + buffer.fill(Qt::transparent); + buffer.setDevicePixelRatio(dpr); + { + auto q = QPainter(&buffer); + Ui::Emoji::Draw(q, emoji, esize, 0, 0); + } + p.drawImage(target, buffer); + } +} + +} // namespace + +EmojiPickerOverlay::Strip::Strip( + QWidget *parent, + not_null owner) +: RpWidget(parent) +, _owner(owner) { + setMouseTracking(true); +} + +void EmojiPickerOverlay::Strip::refresh() { + const auto &sel = _owner->_selectedList; + const auto &recent = _owner->_recent; + const auto item = st::stickersEmojiPickerItemSize; + const auto skip = st::stickersEmojiPickerItemSkip; + const auto w = width(); + if (w <= 0 || item <= 0) { + _cells.clear(); + update(); + return; + } + const auto capacity = std::max(1, (w + skip) / (item + skip)); + + auto order = std::vector(); + order.reserve(sel.size() + recent.size()); + for (const auto emoji : sel) { + order.push_back(emoji); + } + for (const auto emoji : recent) { + const auto already = std::find(sel.begin(), sel.end(), emoji) + != sel.end(); + if (!already) { + order.push_back(emoji); + } + } + if (int(order.size()) > capacity) { + order.resize(capacity); + } + + _cells.clear(); + _cells.reserve(order.size()); + auto x = 0; + const auto y = (height() - item) / 2; + for (const auto emoji : order) { + _cells.push_back({ emoji, QRect(x, y, item, item) }); + x += item + skip; + } + _hover = -1; + _pressed = -1; + update(); +} + +int EmojiPickerOverlay::Strip::cellAtPoint(QPoint p) const { + for (auto i = 0; i != int(_cells.size()); ++i) { + if (_cells[i].rect.contains(p)) { + return i; + } + } + return -1; +} + +void EmojiPickerOverlay::Strip::updateHover(int index) { + if (_hover == index) { + return; + } + _hover = index; + update(); +} + +void EmojiPickerOverlay::Strip::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + for (auto i = 0; i != int(_cells.size()); ++i) { + const auto &cell = _cells[i]; + const auto selected = _owner->_selectedList.end() + != std::find( + _owner->_selectedList.begin(), + _owner->_selectedList.end(), + cell.emoji); + DrawEmojiCell(p, cell.rect, cell.emoji, selected, i == _hover); + } +} + +void EmojiPickerOverlay::Strip::mouseMoveEvent(QMouseEvent *e) { + updateHover(cellAtPoint(e->pos())); +} + +void EmojiPickerOverlay::Strip::mousePressEvent(QMouseEvent *e) { + _pressed = cellAtPoint(e->pos()); +} + +void EmojiPickerOverlay::Strip::mouseReleaseEvent(QMouseEvent *e) { + const auto released = cellAtPoint(e->pos()); + const auto index = _pressed; + _pressed = -1; + if (released == index && index >= 0 && index < int(_cells.size())) { + _owner->toggleEmoji(_cells[index].emoji, false); + } +} + +void EmojiPickerOverlay::Strip::leaveEventHook(QEvent *e) { + updateHover(-1); +} + +EmojiPickerOverlay::Grid::Grid( + QWidget *parent, + not_null owner) +: RpWidget(parent) +, _owner(owner) { + setMouseTracking(true); +} + +void EmojiPickerOverlay::Grid::setEmojis(std::vector emojis) { + _emojis = std::move(emojis); + relayoutCells(); +} + +int EmojiPickerOverlay::Grid::resizeGetHeight(int newWidth) { + resize(newWidth, 0); + relayoutCells(); + return height(); +} + +void EmojiPickerOverlay::Grid::refresh() { + update(); +} + +void EmojiPickerOverlay::Grid::relayoutCells() { + const auto item = st::stickersEmojiPickerItemSize; + const auto skip = st::stickersEmojiPickerItemSkip; + const auto w = width(); + _columns = std::max(1, (w + skip) / (item + skip)); + _cells.clear(); + _cells.reserve(_emojis.size()); + auto col = 0; + auto row = 0; + for (const auto emoji : _emojis) { + const auto x = col * (item + skip); + const auto y = row * (item + skip); + _cells.push_back({ emoji, QRect(x, y, item, item) }); + if (++col >= _columns) { + col = 0; + ++row; + } + } + const auto fullRows = row + (col > 0 ? 1 : 0); + const auto h = fullRows > 0 + ? (fullRows * item + (fullRows - 1) * skip) + : 0; + resize(w, h); + _hover = -1; + _pressed = -1; + update(); +} + +int EmojiPickerOverlay::Grid::cellAtPoint(QPoint p) const { + for (auto i = 0; i != int(_cells.size()); ++i) { + if (_cells[i].rect.contains(p)) { + return i; + } + } + return -1; +} + +void EmojiPickerOverlay::Grid::updateHover(int index) { + if (_hover == index) { + return; + } + _hover = index; + update(); +} + +void EmojiPickerOverlay::Grid::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + const auto clip = e->rect(); + for (auto i = 0; i != int(_cells.size()); ++i) { + const auto &cell = _cells[i]; + if (!cell.rect.intersects(clip)) { + continue; + } + const auto selected = _owner->_selectedList.end() + != std::find( + _owner->_selectedList.begin(), + _owner->_selectedList.end(), + cell.emoji); + DrawEmojiCell(p, cell.rect, cell.emoji, selected, i == _hover); + } +} + +void EmojiPickerOverlay::Grid::mouseMoveEvent(QMouseEvent *e) { + updateHover(cellAtPoint(e->pos())); +} + +void EmojiPickerOverlay::Grid::mousePressEvent(QMouseEvent *e) { + _pressed = cellAtPoint(e->pos()); +} + +void EmojiPickerOverlay::Grid::mouseReleaseEvent(QMouseEvent *e) { + const auto released = cellAtPoint(e->pos()); + const auto index = _pressed; + _pressed = -1; + if (released == index && index >= 0 && index < int(_cells.size())) { + _owner->toggleEmoji(_cells[index].emoji, true); + } +} + +void EmojiPickerOverlay::Grid::leaveEventHook(QEvent *e) { + updateHover(-1); +} + +EmojiPickerOverlay::Metrics EmojiPickerOverlay::EstimateMetrics( + const QString &aboutText) { + const auto tailHeight = st::stickersEmojiPickerStripBubble.height(); + const auto shadowExtent = Ui::BoxShadow::ExtendFor( + st::stickersEmojiPickerBoxShadow); + const auto &pad = st::stickersEmojiPickerPadding; + auto about = Ui::FlatLabel( + nullptr, + aboutText, + st::stickersEmojiPickerAbout); + const auto collapsedHeight = pad.top() + + about.height() + + st::stickersEmojiPickerStripHeight + + pad.bottom(); + const auto expandedHeight = collapsedHeight + + st::stickersEmojiPickerExpandedHeight; + const auto shadowAndTail = shadowExtent.top() + + shadowExtent.bottom() + + tailHeight; + return { + .shadowExtent = shadowExtent, + .tailHeight = tailHeight, + .collapsedHeight = collapsedHeight, + .expandedHeight = expandedHeight, + .totalCollapsedHeight = collapsedHeight + shadowAndTail, + .totalExpandedHeight = expandedHeight + shadowAndTail, + }; +} + +EmojiPickerOverlay::EmojiPickerOverlay( + QWidget *parent, + EmojiPickerOverlayDescriptor descriptor) +: RpWidget(parent) +, _aboutText(std::move(descriptor.aboutText)) +, _recent(descriptor.recent.empty() + ? DefaultRecentVector() + : std::move(descriptor.recent)) +, _maxSelected(descriptor.maxSelected) +, _allowExpand(descriptor.allowExpand) +, _selectedList(std::move(descriptor.initialSelected)) +, _shadow(st::stickersEmojiPickerBoxShadow) { + _allForGrid = BuildAllEmojis(); + + _about = std::make_unique( + this, + _aboutText, + st::stickersEmojiPickerAbout); + + _strip = Ui::CreateChild(this, this); + + if (_allowExpand) { + _expandButton = Ui::CreateChild(this); + _expandButton->resize( + st::stickersEmojiPickerExpandSize, + st::stickersEmojiPickerExpandSize); + _expandButton->setClickedCallback([=] { + setExpanded(!_expanded.current()); + }); + _expandButton->paintRequest( + ) | rpl::on_next([=](const QRect &clip) { + auto p = QPainter(_expandButton); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(st::stickersEmojiPickerExpandBg); + p.drawEllipse(_expandButton->rect()); + const auto &icon = _expanded.current() + ? st::stickersEmojiPickerCollapseIcon + : st::stickersEmojiPickerExpandIcon; + const auto x = (_expandButton->width() - icon.width()) / 2; + const auto y = (_expandButton->height() - icon.height()) / 2; + icon.paint(p, x, y, _expandButton->width()); + }, _expandButton->lifetime()); + + _scroll = std::make_unique( + this, + st::stickersEmojiPickerScroll); + _scroll->setFrameStyle(QFrame::NoFrame); + _scroll->hide(); + const auto gridPtr = _scroll->setOwnedWidget( + object_ptr(_scroll.get(), this)); + _grid = gridPtr.data(); + _grid->setEmojis(_allForGrid); + } + + _selectedVar = _selectedList; + resize(width(), totalExpandedHeight()); +} + +QMargins EmojiPickerOverlay::shadowExtent() const { + return _shadow.extend(); +} + +int EmojiPickerOverlay::totalCollapsedHeight() const { + const auto ext = _shadow.extend(); + return collapsedHeight() + ext.top() + ext.bottom() + tailHeight(); +} + +int EmojiPickerOverlay::totalExpandedHeight() const { + const auto ext = _shadow.extend(); + return expandedHeight() + ext.top() + ext.bottom() + tailHeight(); +} + +QRect EmojiPickerOverlay::bubbleRect() const { + const auto ext = _shadow.extend(); + return QRect( + ext.left(), + ext.top(), + width() - ext.left() - ext.right(), + height() - ext.top() - ext.bottom() - tailHeight()); +} + +QRect EmojiPickerOverlay::bubbleShownRect() const { + const auto r = bubbleRect(); + return QRect(r.x(), r.y(), r.width(), currentShownHeight()); +} + +EmojiPickerOverlay::~EmojiPickerOverlay() = default; + +const std::vector &EmojiPickerOverlay::selected() const { + return _selectedList; +} + +rpl::producer> +EmojiPickerOverlay::selectedValue() const { + return _selectedVar.value(); +} + +void EmojiPickerOverlay::setExpanded(bool expanded) { + if (!_allowExpand || _expanded.current() == expanded) { + return; + } + startExpandAnimation(expanded); + _expanded = expanded; + if (_expandButton) { + _expandButton->update(); + } +} + +void EmojiPickerOverlay::startExpandAnimation(bool expanded) { + const auto from = _expandAnim.value(expanded ? 0. : 1.); + const auto to = expanded ? 1. : 0.; + _expandAnim.start( + [=] { applyExpandProgress(); }, + from, + to, + st::slideWrapDuration, + anim::easeOutCirc); + applyExpandProgress(); +} + +float64 EmojiPickerOverlay::currentExpandValue() const { + return _expandAnim.value(_expanded.current() ? 1. : 0.); +} + +int EmojiPickerOverlay::currentShownHeight() const { + const auto progress = currentExpandValue(); + return anim::interpolate( + collapsedHeight(), + expandedHeight(), + progress); +} + +void EmojiPickerOverlay::applyExpandProgress() { + if (_scroll) { + const auto progress = currentExpandValue(); + _scroll->setVisible(progress > 0.); + } + relayout(); + update(); +} + +bool EmojiPickerOverlay::expanded() const { + return _expanded.current(); +} + +rpl::producer EmojiPickerOverlay::expandedValue() const { + return _expanded.value(); +} + +int EmojiPickerOverlay::collapsedHeight() const { + const auto &pad = st::stickersEmojiPickerPadding; + const auto aboutH = _about ? _about->height() : 0; + return pad.top() + + aboutH + + st::stickersEmojiPickerStripHeight + + pad.bottom(); +} + +int EmojiPickerOverlay::expandedHeight() const { + return collapsedHeight() + st::stickersEmojiPickerExpandedHeight; +} + +void EmojiPickerOverlay::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + const auto progress = currentExpandValue(); + const auto shown = bubbleShownRect(); + const auto radius = st::stickersEmojiPickerExpandedRadius; + + _shadow.paint(p, shown, radius); + p.setPen(Qt::NoPen); + p.setBrush(st::stickersEmojiPickerBg); + p.drawRoundedRect(shown, radius, radius); + + if (progress < 1.) { + paintTailBubble(p, shown, 1. - progress); + } +} + +void EmojiPickerOverlay::paintTailBubble( + QPainter &p, + const QRect &bubble, + float64 opacity) { + const auto &icon = st::stickersEmojiPickerStripBubble; + const auto offsetRight = st::stickersEmojiPickerStripBubbleRight; + const auto x = bubble.right() + 1 - offsetRight - icon.width(); + const auto y = bubble.bottom() + 1; + if (opacity >= 1.) { + icon.paint(p, x, y, width()); + } else { + p.save(); + p.setOpacity(opacity); + icon.paint(p, x, y, width()); + p.restore(); + } +} + +void EmojiPickerOverlay::resizeEvent(QResizeEvent *e) { + relayout(); +} + +void EmojiPickerOverlay::mousePressEvent(QMouseEvent *e) { + if (!bubbleShownRect().contains(e->pos())) { + e->ignore(); + } +} + +int EmojiPickerOverlay::tailHeight() const { + return st::stickersEmojiPickerStripBubble.height(); +} + +void EmojiPickerOverlay::relayout() { + const auto &pad = st::stickersEmojiPickerPadding; + const auto bubble = bubbleRect(); + const auto bubbleShown = currentShownHeight(); + if (_about) { + _about->resizeToWidth(bubble.width() - pad.left() - pad.right()); + _about->moveToLeft(bubble.left() + pad.left(), bubble.top() + pad.top()); + } + const auto aboutBottom = _about + ? (_about->y() + _about->height()) + : (bubble.top() + pad.top()); + + const auto stripTop = aboutBottom; + const auto stripH = st::stickersEmojiPickerStripHeight; + const auto expandSize = _expandButton + ? _expandButton->width() + : 0; + const auto expandGap = _expandButton + ? st::stickersEmojiPickerItemSkip + : 0; + const auto stripW = bubble.width() + - pad.left() + - pad.right() + - expandSize + - expandGap; + _strip->setGeometry(bubble.left() + pad.left(), stripTop, stripW, stripH); + _strip->refresh(); + + if (_expandButton) { + const auto bx = bubble.right() + 1 - pad.right() - expandSize; + const auto by = stripTop + (stripH - expandSize) / 2; + _expandButton->moveToLeft(bx, by); + } + + if (_scroll) { + const auto scrollTop = stripTop + stripH; + const auto bubbleBottom = bubble.top() + bubbleShown; + const auto scrollH = std::max( + 0, + bubbleBottom - scrollTop - pad.bottom()); + const auto scrollContentWidth = bubble.width() + - pad.left() + - pad.right(); + const auto scrollAreaWidth = scrollContentWidth + + pad.right(); + _scroll->setGeometry( + bubble.left() + pad.left(), + scrollTop, + scrollAreaWidth, + scrollH); + if (_grid) { + _grid->resizeGetHeight(scrollContentWidth); + } + } +} + +void EmojiPickerOverlay::toggleEmoji(EmojiPtr emoji, bool fromGrid) { + if (!emoji) { + return; + } + const auto it = std::find( + _selectedList.begin(), + _selectedList.end(), + emoji); + if (it != _selectedList.end()) { + _selectedList.erase(it); + } else { + if (_maxSelected > 0 && int(_selectedList.size()) >= _maxSelected) { + return; + } + _selectedList.push_back(emoji); + } + notifySelectionChanged(); + if (fromGrid) { + setExpanded(false); + } +} + +void EmojiPickerOverlay::notifySelectionChanged() { + _selectedVar = _selectedList; + if (_strip) { + _strip->refresh(); + } + if (_grid) { + _grid->refresh(); + } +} + +} // namespace ChatHelpers diff --git a/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h new file mode 100644 index 0000000000..80432ae8b4 --- /dev/null +++ b/Telegram/SourceFiles/chat_helpers/emoji_picker_overlay.h @@ -0,0 +1,103 @@ +/* +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" +#include "ui/effects/animations.h" +#include "ui/emoji_config.h" +#include "ui/widgets/shadow.h" + +namespace Ui { +class AbstractButton; +class FlatLabel; +class ScrollArea; +} // namespace Ui + +namespace ChatHelpers { + +struct EmojiPickerOverlayDescriptor { + QString aboutText; + std::vector recent; + int maxSelected = 0; + bool allowExpand = true; + std::vector initialSelected; +}; + +class EmojiPickerOverlay final : public Ui::RpWidget { +public: + EmojiPickerOverlay( + QWidget *parent, + EmojiPickerOverlayDescriptor descriptor); + ~EmojiPickerOverlay(); + + struct Metrics { + QMargins shadowExtent; + int tailHeight = 0; + int collapsedHeight = 0; + int expandedHeight = 0; + int totalCollapsedHeight = 0; + int totalExpandedHeight = 0; + }; + [[nodiscard]] static Metrics EstimateMetrics(const QString &aboutText); + + [[nodiscard]] const std::vector &selected() const; + [[nodiscard]] rpl::producer> selectedValue() const; + + void setExpanded(bool expanded); + [[nodiscard]] bool expanded() const; + [[nodiscard]] rpl::producer expandedValue() const; + + [[nodiscard]] int collapsedHeight() const; + [[nodiscard]] int expandedHeight() const; + [[nodiscard]] QMargins shadowExtent() const; + [[nodiscard]] int totalCollapsedHeight() const; + [[nodiscard]] int totalExpandedHeight() const; + +protected: + void paintEvent(QPaintEvent *e) override; + void resizeEvent(QResizeEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; + +private: + class Strip; + class Grid; + + void relayout(); + void toggleEmoji(EmojiPtr emoji, bool fromGrid); + void notifySelectionChanged(); + void startExpandAnimation(bool expanded); + void applyExpandProgress(); + void paintTailBubble(QPainter &p, const QRect &bubble, float64 opacity); + [[nodiscard]] float64 currentExpandValue() const; + [[nodiscard]] int currentShownHeight() const; + [[nodiscard]] int tailHeight() const; + [[nodiscard]] QRect bubbleRect() const; + [[nodiscard]] QRect bubbleShownRect() const; + + const QString _aboutText; + const std::vector _recent; + const int _maxSelected; + const bool _allowExpand; + + std::vector _allForGrid; + + std::vector _selectedList; + rpl::variable> _selectedVar; + rpl::variable _expanded = false; + + std::unique_ptr _about; + Strip *_strip = nullptr; + Ui::AbstractButton *_expandButton = nullptr; + std::unique_ptr _scroll; + Grid *_grid = nullptr; + Ui::Animations::Simple _expandAnim; + Ui::BoxShadow _shadow; + +}; + +} // namespace ChatHelpers diff --git a/Telegram/SourceFiles/chat_helpers/spellchecker_common.cpp b/Telegram/SourceFiles/chat_helpers/spellchecker_common.cpp index 67e2e79542..2ff059c63f 100644 --- a/Telegram/SourceFiles/chat_helpers/spellchecker_common.cpp +++ b/Telegram/SourceFiles/chat_helpers/spellchecker_common.cpp @@ -10,6 +10,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #ifndef TDESKTOP_DISABLE_SPELLCHECK #include "base/platform/base_platform_info.h" +#include "base/weak_ptr.h" #include "base/zlib_help.h" #include "data/data_session.h" #include "lang/lang_instance.h" @@ -18,12 +19,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "main/main_domain.h" #include "main/main_session.h" #include "mainwidget.h" +#include "mtproto/dedicated_file_loader.h" #include "spellcheck/platform/platform_spellcheck.h" #include "spellcheck/spellcheck_utils.h" #include "spellcheck/spellcheck_value.h" #include "core/application.h" #include "core/core_settings.h" +#include +#include +#include #include #include @@ -59,53 +64,187 @@ inline auto LanguageFromLocale(QLocale loc) { : int(locLang); } -const auto kDictionaries = { - Dict{{ QLocale::English, 649, 174'516, "English" }}, // en_US - Dict{{ QLocale::Bulgarian, 594, 229'658, "\xd0\x91\xd1\x8a\xd0\xbb\xd0\xb3\xd0\xb0\xd1\x80\xd1\x81\xd0\xba\xd0\xb8" }}, // bg_BG - Dict{{ QLocale::Catalan, 595, 417'611, "\x43\x61\x74\x61\x6c\xc3\xa0" }}, // ca_ES - Dict{{ QLocale::Czech, 596, 860'286, "\xc4\x8c\x65\xc5\xa1\x74\x69\x6e\x61" }}, // cs_CZ - Dict{{ QLocale::Welsh, 597, 177'305, "\x43\x79\x6d\x72\x61\x65\x67" }}, // cy_GB - Dict{{ QLocale::Danish, 598, 345'874, "\x44\x61\x6e\x73\x6b" }}, // da_DK - Dict{{ QLocale::German, 599, 2'412'780, "\x44\x65\x75\x74\x73\x63\x68" }}, // de_DE - Dict{{ QLocale::Greek, 600, 1'389'160, "\xce\x95\xce\xbb\xce\xbb\xce\xb7\xce\xbd\xce\xb9\xce\xba\xce\xac" }}, // el_GR - Dict{{ LWC(QLocale::English, QLocale::Australia), 601, 175'266, "English (Australia)" }}, // en_AU - Dict{{ LWC(QLocale::English, QLocale::Canada), 602, 174'295, "English (Canada)" }}, // en_CA - Dict{{ LWC(QLocale::English, QLocale::UnitedKingdom), 603, 174'433, "English (United Kingdom)" }}, // en_GB - Dict{{ QLocale::Spanish, 604, 264'717, "\x45\x73\x70\x61\xc3\xb1\x6f\x6c" }}, // es_ES - Dict{{ QLocale::Estonian, 605, 757'394, "\x45\x65\x73\x74\x69" }}, // et_EE - Dict{{ QLocale::Persian, 606, 333'911, "\xd9\x81\xd8\xa7\xd8\xb1\xd8\xb3\xdb\x8c" }}, // fa_IR - Dict{{ QLocale::French, 607, 321'391, "\x46\x72\x61\x6e\xc3\xa7\x61\x69\x73" }}, // fr_FR - Dict{{ QLocale::Hebrew, 608, 622'550, "\xd7\xa2\xd7\x91\xd7\xa8\xd7\x99\xd7\xaa" }}, // he_IL - Dict{{ QLocale::Hindi, 609, 56'105, "\xe0\xa4\xb9\xe0\xa4\xbf\xe0\xa4\xa8\xe0\xa5\x8d\xe0\xa4\xa6\xe0\xa5\x80" }}, // hi_IN - Dict{{ QLocale::Croatian, 610, 668'876, "\x48\x72\x76\x61\x74\x73\x6b\x69" }}, // hr_HR - Dict{{ QLocale::Hungarian, 611, 660'402, "\x4d\x61\x67\x79\x61\x72" }}, // hu_HU - Dict{{ QLocale::Armenian, 612, 928'746, "\xd5\x80\xd5\xa1\xd5\xb5\xd5\xa5\xd6\x80\xd5\xa5\xd5\xb6" }}, // hy_AM - Dict{{ QLocale::Indonesian, 613, 100'134, "\x49\x6e\x64\x6f\x6e\x65\x73\x69\x61" }}, // id_ID - Dict{{ QLocale::Italian, 614, 324'613, "\x49\x74\x61\x6c\x69\x61\x6e\x6f" }}, // it_IT - Dict{{ QLocale::Korean, 615, 1'256'987, "\xed\x95\x9c\xea\xb5\xad\xec\x96\xb4" }}, // ko_KR - Dict{{ QLocale::Lithuanian, 616, 267'427, "\x4c\x69\x65\x74\x75\x76\x69\xc5\xb3" }}, // lt_LT - Dict{{ QLocale::Latvian, 617, 641'602, "\x4c\x61\x74\x76\x69\x65\xc5\xa1\x75" }}, // lv_LV - Dict{{ QLocale::NorwegianBokmal, 618, 588'650, "\x4e\x6f\x72\x73\x6b" }}, // nb_NO - Dict{{ QLocale::Dutch, 619, 743'406, "\x4e\x65\x64\x65\x72\x6c\x61\x6e\x64\x73" }}, // nl_NL - Dict{{ QLocale::Polish, 620, 1'015'747, "\x50\x6f\x6c\x73\x6b\x69" }}, // pl_PL - Dict{{ QLocale::Portuguese, 621, 1'231'999, "\x50\x6f\x72\x74\x75\x67\x75\xc3\xaa\x73 (Brazil)" }}, // pt_BR - Dict{{ LWC(QLocale::Portuguese, QLocale::Portugal), 622, 138'571, "\x50\x6f\x72\x74\x75\x67\x75\xc3\xaa\x73" }}, // pt_PT - Dict{{ QLocale::Romanian, 623, 455'643, "\x52\x6f\x6d\xc3\xa2\x6e\xc4\x83" }}, // ro_RO - Dict{{ QLocale::Russian, 624, 463'194, "\xd0\xa0\xd1\x83\xd1\x81\xd1\x81\xd0\xba\xd0\xb8\xd0\xb9" }}, // ru_RU - Dict{{ QLocale::Slovak, 625, 525'328, "\x53\x6c\x6f\x76\x65\x6e\xc4\x8d\x69\x6e\x61" }}, // sk_SK - Dict{{ QLocale::Slovenian, 626, 1'143'710, "\x53\x6c\x6f\x76\x65\x6e\xc5\xa1\xc4\x8d\x69\x6e\x61" }}, // sl_SI - Dict{{ QLocale::Albanian, 627, 583'412, "\x53\x68\x71\x69\x70" }}, // sq_AL - Dict{{ QLocale::Swedish, 628, 593'877, "\x53\x76\x65\x6e\x73\x6b\x61" }}, // sv_SE - Dict{{ QLocale::Tamil, 629, 323'193, "\xe0\xae\xa4\xe0\xae\xae\xe0\xae\xbf\xe0\xae\xb4\xe0\xaf\x8d" }}, // ta_IN - Dict{{ QLocale::Tajik, 630, 369'931, "\xd0\xa2\xd0\xbe\xd2\xb7\xd0\xb8\xd0\xba\xd3\xa3" }}, // tg_TG - Dict{{ QLocale::Turkish, 631, 4'301'099, "\x54\xc3\xbc\x72\x6b\xc3\xa7\x65" }}, // tr_TR - Dict{{ QLocale::Ukrainian, 632, 445'711, "\xd0\xa3\xd0\xba\xd1\x80\xd0\xb0\xd1\x97\xd0\xbd\xd1\x81\xd1\x8c\xd0\xba\xd0\xb0" }}, // uk_UA - Dict{{ QLocale::Vietnamese, 633, 12'949, "\x54\x69\xe1\xba\xbf\x6e\x67\x20\x56\x69\xe1\xbb\x87\x74" }}, // vi_VN - // The Tajik code is 'tg_TG' in Chromium, but QT has only 'tg_TJ'. +constexpr auto kDictionariesManifestChannel = "tdhbcfiles"_cs; +constexpr auto kDictionariesManifestPostId = 2949; + +// Runtime-loaded dictionaries manifest. Kept in memory only: fetched from +// the pinned JSON post the first time something actually needs it +// (Manage Dictionaries, auto-download). Nothing persists to disk, so an +// install whose enabled dictionaries are all on disk never hits the net. +std::vector DictionariesList; +rpl::event_stream<> DictionariesListChanged; + +void EnsurePath(); + +bool ParseLocation(const QString &text, QString &channel, int &postId) { + const auto sep = text.indexOf('#'); + if (sep <= 0 || sep == text.size() - 1) { + return false; + } + auto ok = false; + const auto parsed = text.mid(sep + 1).toInt(&ok); + if (!ok || parsed <= 0) { + return false; + } + channel = text.left(sep); + postId = parsed; + return true; +} + +std::vector ParseManifest(const QByteArray &bytes) { + auto result = std::vector(); + auto err = QJsonParseError(); + const auto doc = QJsonDocument::fromJson(bytes, &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + LOG(("Spellcheck Error: manifest JSON parse failed: %1" + ).arg(err.errorString())); + return result; + } + const auto list = doc.object().value(u"dictionaries"_q).toArray(); + result.reserve(list.size()); + for (const auto &v : list) { + const auto obj = v.toObject(); + auto d = Dict(); + d.id = obj.value(u"id"_q).toInt(); + d.size = int64(obj.value(u"size"_q).toDouble()); + d.name = obj.value(u"name"_q).toString(); + const auto location = obj.value(u"location"_q).toString(); + if (!d.id + || d.name.isEmpty() + || !ParseLocation(location, d.channel, d.postId)) { + continue; + } + result.push_back(std::move(d)); + } + return result; +} + +class DictManifestLoader final : public base::has_weak_ptr { +public: + explicit DictManifestLoader(base::weak_ptr session); + + void start(); + +private: + void resolved(const MTPInputChannel &channel); + void received(const MTPmessages_Messages &result); + void apply(const QByteArray &bytes); + void finish(); + + MTP::WeakInstance _mtp; + }; +std::shared_ptr ActiveManifestLoader; + +DictManifestLoader::DictManifestLoader(base::weak_ptr session) +: _mtp(session) { +} + +void DictManifestLoader::start() { + if (!_mtp.valid()) { + finish(); + return; + } + const auto weak = base::make_weak(this); + MTP::ResolveChannel(&_mtp, kDictionariesManifestChannel.utf16(), [=]( + const MTPInputChannel &channel) { + if (const auto strong = weak.get()) { + strong->resolved(channel); + } + }, [=] { + if (const auto strong = weak.get()) { + strong->finish(); + } + }); +} + +void DictManifestLoader::resolved(const MTPInputChannel &channel) { + const auto weak = base::make_weak(this); + _mtp.send( + MTPchannels_GetMessages( + channel, + MTP_vector(1, + MTP_inputMessageID( + MTP_int(kDictionariesManifestPostId)))), + [=](const MTPmessages_Messages &result) { + if (const auto strong = weak.get()) { + strong->received(result); + } + }, + [=](const MTP::Error &) { + if (const auto strong = weak.get()) { + strong->finish(); + } + }); +} + +void DictManifestLoader::received(const MTPmessages_Messages &result) { + const auto message = MTP::GetMessagesElement(result); + if (!message || message->type() != mtpc_message) { + LOG(("Spellcheck Error: manifest message not found.")); + finish(); + return; + } + apply(message->c_message().vmessage().v); + finish(); +} + +void DictManifestLoader::apply(const QByteArray &bytes) { + auto parsed = ParseManifest(bytes); + if (parsed.empty()) { + LOG(("Spellcheck Error: manifest empty or unparseable.")); + return; + } + DictionariesList = std::move(parsed); + DictionariesListChanged.fire({}); +} + +void DictManifestLoader::finish() { + crl::on_main([] { + ActiveManifestLoader.reset(); + }); +} + +void StartManifestRefresh(not_null session) { + if (ActiveManifestLoader) { + return; + } + ActiveManifestLoader = std::make_shared( + base::make_weak(session)); + ActiveManifestLoader->start(); +} + +// Callers waiting for the first manifest arrival in this session. +// We don't cache manifest to disk, so each cold start begins empty; +// queued callbacks fire once when the MTP fetch lands. +std::vector> ManifestPending; +rpl::lifetime ManifestPendingSubscription; + +void EnsureManifestThen( + not_null session, + Fn callback) { + if (!DictionariesList.empty()) { + callback(); + return; + } + const auto firstCaller = ManifestPending.empty(); + ManifestPending.push_back(std::move(callback)); + if (firstCaller) { + DictionariesListChanged.events( + ) | rpl::take(1) | rpl::on_next([] { + auto fns = std::move(ManifestPending); + ManifestPending.clear(); + for (auto &fn : fns) { + fn(); + } + }, ManifestPendingSubscription); + } + StartManifestRefresh(session); +} + inline auto IsSupportedLang(int lang) { - return ranges::contains(kDictionaries, lang, &Dict::id); + return ranges::contains(DictionariesList, lang, &Dict::id); } void EnsurePath() { @@ -144,9 +283,12 @@ void DownloadDictionaryInBackground( if (DictionaryExists(id)) { auto dicts = Core::App().settings().dictionariesEnabled(); - if (!ranges::contains(dicts, id)) { + if (ranges::contains(dicts, id)) { + Platform::Spellchecker::UpdateLanguages(dicts); + } else { dicts.push_back(id); - Core::App().settings().setDictionariesEnabled(std::move(dicts)); + Core::App().settings().setDictionariesEnabled( + std::move(dicts)); Core::App().saveSettingsDelayed(); } } @@ -230,17 +372,28 @@ void DictLoader::fail() { } std::vector Dictionaries() { - return kDictionaries | ranges::to_vector; + return DictionariesList; +} + +rpl::producer<> DictionariesChanged() { + return DictionariesListChanged.events(); +} + +void RefreshDictionariesManifest(not_null session) { + StartManifestRefresh(session); } int64 GetDownloadSize(int id) { - return ranges::find(kDictionaries, id, &Spellchecker::Dict::id)->size; + const auto i = ranges::find(DictionariesList, id, &Dict::id); + return (i == end(DictionariesList)) ? 0 : i->size; } MTP::DedicatedLoader::Location GetDownloadLocation(int id) { - const auto username = kCloudLocationUsername.utf16(); - const auto i = ranges::find(kDictionaries, id, &Spellchecker::Dict::id); - return MTP::DedicatedLoader::Location{ username, i->postId }; + const auto i = ranges::find(DictionariesList, id, &Dict::id); + if (i == end(DictionariesList)) { + return MTP::DedicatedLoader::Location{}; + } + return MTP::DedicatedLoader::Location{ i->channel, i->postId }; } QString DictPathByLangId(int langId) { @@ -256,7 +409,24 @@ QString DictionariesPath() { bool UnpackDictionary(const QString &path, int langId) { const auto folder = DictPathByLangId(langId); - return UnpackBlob(path, folder, IsGoodPartName); + if (!UnpackBlob(path, folder, IsGoodPartName)) { + return false; + } + // The Serbian archive ships "sr_Cyrl_RS.{dic,aff}", but + // QLocale(QLocale::Serbian).name() is "sr_RS" on our Qt, so the + // Hunspell loader and DictionaryExists miss them. Rename to the + // expected stem. + if (langId == int(QLocale::Serbian)) { + const auto dir = QDir(folder); + for (const auto &ext : kDictExtensions) { + const auto from = u"sr_Cyrl_RS.%1"_q.arg(ext); + const auto to = u"sr_RS.%1"_q.arg(ext); + if (dir.exists(from) && !dir.exists(to)) { + QFile::rename(dir.filePath(from), dir.filePath(to)); + } + } + } + return true; } bool DictionaryExists(int langId) { @@ -316,26 +486,26 @@ rpl::producer ButtonManageDictsState( if (!Core::App().settings().spellcheckerEnabled()) { return QString(); } - if (!Core::App().settings().dictionariesEnabled().size()) { + const auto dicts = Core::App().settings().dictionariesEnabled(); + if (dicts.empty()) { return QString(); } - const auto dicts = Core::App().settings().dictionariesEnabled(); const auto filtered = ranges::views::all( dicts ) | ranges::views::filter( DictionaryExists ) | ranges::to_vector; - const auto active = Platform::Spellchecker::ActiveLanguages(); - return (active.size() == filtered.size()) - ? QString::number(filtered.size()) - : tr::lng_contacts_loading(tr::now); + return (filtered.size() < dicts.size()) + ? tr::lng_contacts_loading(tr::now) + : QString::number(filtered.size()); }; return rpl::single( computeString() ) | rpl::then( rpl::merge( Spellchecker::SupportedScriptsChanged(), + Spellchecker::DictionariesChanged(), Core::App().settings().dictionariesEnabledChanges( ) | rpl::to_empty, Core::App().settings().spellcheckerEnabledChanges( @@ -418,12 +588,18 @@ void Start(not_null session) { return; } const auto l = LanguageFromLocale(method->locale()); - if (!IsSupportedLang(l) || DictionaryExists(l)) { + // Avoid pulling the manifest just because an input-method + // locale flipped; only fetch if we'd actually need to + // download something for the new locale. + if (DictionaryExists(l)) { return; } - crl::on_main(session, [=] { + EnsureManifestThen(session, crl::guard(session, [=] { + if (!IsSupportedLang(l) || DictionaryExists(l)) { + return; + } DownloadDictionaryInBackground(session, 0, { l }); - }); + })); }; QObject::connect( method, @@ -437,8 +613,17 @@ void Start(not_null session) { if (!loaded) { return; } - - DownloadDictionaryInBackground(session, 0, DefaultLanguages()); + const auto enabled = settings->dictionariesEnabled(); + if (!enabled.empty() + && ranges::all_of(enabled, &DictionaryExists)) { + // Every previously-enabled dictionary is already on + // disk; no manifest fetch, no network traffic. + return; + } + EnsureManifestThen(session, crl::guard(session, [=] { + DownloadDictionaryInBackground( + session, 0, DefaultLanguages()); + })); }, lifetime); connectInput(); diff --git a/Telegram/SourceFiles/chat_helpers/spellchecker_common.h b/Telegram/SourceFiles/chat_helpers/spellchecker_common.h index f0e02879ae..30da85bda9 100644 --- a/Telegram/SourceFiles/chat_helpers/spellchecker_common.h +++ b/Telegram/SourceFiles/chat_helpers/spellchecker_common.h @@ -19,6 +19,7 @@ class Session; namespace Spellchecker { struct Dict : public Storage::CloudBlob::Blob { + QString channel; }; int64 GetDownloadSize(int id); @@ -33,6 +34,8 @@ bool RemoveDictionary(int langId); bool WriteDefaultDictionary(); std::vector Dictionaries(); +rpl::producer<> DictionariesChanged(); +void RefreshDictionariesManifest(not_null session); void Start(not_null session); [[nodiscard]] rpl::producer ButtonManageDictsState( diff --git a/Telegram/SourceFiles/chat_helpers/stickers_emoji_pack.cpp b/Telegram/SourceFiles/chat_helpers/stickers_emoji_pack.cpp index 96ddbf0204..1e321ae4e4 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_emoji_pack.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_emoji_pack.cpp @@ -122,7 +122,9 @@ EmojiPack::EmojiPack(not_null session) EmojiPack::~EmojiPack() = default; bool EmojiPack::add(not_null view) { - if (const auto custom = view->onlyCustomEmoji()) { + if (view->data()->textAppearing()) { + return false; + } else if (const auto custom = view->onlyCustomEmoji()) { _onlyCustomItems.emplace(view); return true; } else if (const auto emoji = view->isolatedEmoji()) { diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp index a0394d1ade..8971ceec58 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.cpp @@ -146,7 +146,8 @@ rpl::producer> GifSectionsValue( [[nodiscard]] std::vector SearchEmoji( const std::vector &query, - base::flat_set &outResultSet) { + base::flat_set &outResultSet, + bool exact) { auto result = std::vector(); const auto pushPlain = [&](EmojiPtr emoji) { if (result.size() < kEmojiSearchLimit @@ -170,7 +171,7 @@ rpl::producer> GifSectionsValue( refreshed = true; keywords.refresh(); } - const auto list = keywords.queryMine(entry); + const auto list = keywords.queryMine(entry, exact); for (const auto &entry : list) { pushPlain(entry.emoji); if (result.size() >= kEmojiSearchLimit) { diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h index a76b1ee21a..e5910d60c4 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_footer.h @@ -70,7 +70,8 @@ struct GifSection { [[nodiscard]] std::vector SearchEmoji( const std::vector &query, - base::flat_set &outResultSet); + base::flat_set &outResultSet, + bool exact = false); struct StickerIcon { explicit StickerIcon(uint64 setId); diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp index 6a9e2b7ff3..b1d3b2cda1 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp @@ -63,6 +63,17 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace ChatHelpers { + +[[nodiscard]] QVector SearchStickersLangCodes() { + auto result = QVector(); + if (const auto method = QGuiApplication::inputMethod()) { + for (const auto &lang : method->locale().uiLanguages()) { + result.push_back(MTP_string(lang)); + } + } + return result; +} + namespace { constexpr auto kSearchRequestDelay = 400; @@ -209,6 +220,7 @@ StickersListWidget::StickersListWidget( , _section(Section::Stickers) , _isMasks(_mode == Mode::Masks) , _isEffects(_mode == Mode::MessageEffects) +, _excludeSetId(descriptor.excludeSetId) , _updateItemsTimer([=] { updateItems(); }) , _updateSetsTimer([=] { updateSets(); }) , _trendingAddBgOver( @@ -369,6 +381,9 @@ void StickersListWidget::visibleTopBottomUpdated( checkVisibleFeatured(visibleTop, visibleBottom); } else { checkVisibleLottie(); + if (_section == Section::Search) { + checkPaginateSearchStickers(visibleTop, visibleBottom); + } } if (_footer) { _footer->validateSelectedIcon( @@ -619,31 +634,52 @@ void StickersListWidget::sendSearchRequest() { return; } + requestSearchStickers(_searchQuery, 0, true); +} + +void StickersListWidget::sendSearchSetsRequest(const QString &query) { + const auto hash = uint64(0); + _searchSetsRequestId = _api.request(MTPmessages_SearchStickerSets( + MTP_flags(0), + MTP_string(query), + MTP_long(hash) + )).done([=](const MTPmessages_FoundStickerSets &result) { + searchResultsDone(query, result); + }).fail([=] { + _searchSetsRequestId = 0; + if (_searchNextQuery == query) { + toggleSearchLoading(false); + } + }).handleAllErrors().send(); +} + +void StickersListWidget::requestSearchStickers( + const QString &query, + int offset, + bool requestSetsOnEmpty) { const auto hash = uint64(0); _searchStickersRequestId = _api.request(MTPmessages_SearchStickers( MTP_flags(0), - MTP_string(_searchQuery), + MTP_string(query), MTPstring(), // emoticon - MTP_vector(), // lang_code - MTP_int(0), // offset - MTP_int(50), // limit + MTP_vector(SearchStickersLangCodes()), + MTP_int(offset), + MTP_int(50), MTP_long(hash) )).done([=](const MTPmessages_FoundStickers &result) { - searchStickersResultsDone(result); + searchStickersResultsDone( + query, + offset, + requestSetsOnEmpty, + result); }).fail([=] { _searchStickersRequestId = 0; - _searchStickersCache.emplace(_searchQuery, std::vector()); - - _searchSetsRequestId = _api.request(MTPmessages_SearchStickerSets( - MTP_flags(0), - MTP_string(_searchQuery), - MTP_long(hash) - )).done([=](const MTPmessages_FoundStickerSets &result) { - searchResultsDone(result); - }).fail([=] { - toggleSearchLoading(false); - _searchSetsRequestId = 0; - }).handleAllErrors().send(); + if (requestSetsOnEmpty) { + _searchStickersCache.emplace(query, std::vector()); + if (_searchNextQuery == query) { + sendSearchSetsRequest(query); + } + } }).handleAllErrors().send(); } @@ -701,6 +737,7 @@ void StickersListWidget::cancelSetsSearch() { _filterStickersCornerEmoji.clear(); _searchSetsCache.clear(); _searchStickersCache.clear(); + _searchStickersNextOffset.clear(); refreshSearchRows(nullptr); } @@ -710,7 +747,7 @@ void StickersListWidget::showSearchResults() { } void StickersListWidget::refreshSearchRows() { - auto it = _searchSetsCache.find(_searchQuery); + auto it = _searchSetsCache.find(_searchNextQuery); auto sets = (it != end(_searchSetsCache)) ? &it->second : nullptr; @@ -969,16 +1006,41 @@ auto StickersListWidget::shownSets() -> std::vector & { } void StickersListWidget::searchStickersResultsDone( + const QString &query, + int requestedOffset, + bool requestSetsOnEmpty, const MTPmessages_FoundStickers &result) { _searchStickersRequestId = 0; + const auto active = (_searchNextQuery == query); result.match([&](const MTPDmessages_foundStickersNotModified &data) { LOG(("API: messages.foundStickersNotModified.")); + if (const auto next = data.vnext_offset()) { + if (next->v > requestedOffset) { + _searchStickersNextOffset[query] = next->v; + } else { + _searchStickersNextOffset.erase(query); + } + } else { + _searchStickersNextOffset.erase(query); + } + _searchStickersCache.emplace(query, std::vector()); + if (!active) { + return; + } + if (requestSetsOnEmpty) { + sendSearchSetsRequest(query); + return; + } + refreshSearchRows(); + checkPaginateSearchStickers( + getVisibleTop(), + getVisibleBottom()); }, [&](const MTPDmessages_foundStickers &data) { - auto it = _searchStickersCache.find(_searchQuery); + auto it = _searchStickersCache.find(query); if (it == _searchStickersCache.cend()) { it = _searchStickersCache.emplace( - _searchQuery, + query, std::vector()).first; } @@ -988,38 +1050,84 @@ void StickersListWidget::searchStickersResultsDone( } } - if (!it->second.empty()) { - toggleSearchLoading(false); + if (const auto next = data.vnext_offset()) { + if (next->v > requestedOffset) { + _searchStickersNextOffset[query] = next->v; + } else { + _searchStickersNextOffset.erase(query); + } + } else { + _searchStickersNextOffset.erase(query); + } + + if (!active) { + return; + } + if (requestSetsOnEmpty && it->second.empty()) { + sendSearchSetsRequest(query); + return; + } + toggleSearchLoading(false); + if (requestSetsOnEmpty) { showSearchResults(); } else { - const auto hash = uint64(0); - _searchSetsRequestId = _api.request(MTPmessages_SearchStickerSets( - MTP_flags(0), - MTP_string(_searchQuery), - MTP_long(hash) - )).done([=](const MTPmessages_FoundStickerSets &result) { - searchResultsDone(result); - }).fail([=] { - toggleSearchLoading(false); - _searchSetsRequestId = 0; - }).handleAllErrors().send(); + refreshSearchRows(); } + checkPaginateSearchStickers( + getVisibleTop(), + getVisibleBottom()); }); } +void StickersListWidget::loadMoreSearchStickers() { + if (_searchStickersRequestId + || _searchQuery.isEmpty() + || _isEffects + || (_searchQuery != _searchNextQuery)) { + return; + } + const auto query = _searchQuery; + const auto offsetIt = _searchStickersNextOffset.find(query); + if (offsetIt == _searchStickersNextOffset.end()) { + return; + } + requestSearchStickers(query, offsetIt->second, false); +} + +void StickersListWidget::checkPaginateSearchStickers( + int visibleTop, + int visibleBottom) { + if (_section != Section::Search + || _searchQuery.isEmpty() + || (_searchQuery != _searchNextQuery) + || _searchStickersRequestId) { + return; + } + const auto visibleHeight = visibleBottom - visibleTop; + if (visibleHeight <= 0) { + return; + } + if (visibleBottom > height() - visibleHeight * kPreloadOfficialPages) { + loadMoreSearchStickers(); + } +} + void StickersListWidget::searchResultsDone( + const QString &query, const MTPmessages_FoundStickerSets &result) { - toggleSearchLoading(false); + if (_searchNextQuery == query) { + toggleSearchLoading(false); + } _searchSetsRequestId = 0; result.match([&](const MTPDmessages_foundStickerSetsNotModified &data) { LOG(("API Error: " "messages.foundStickerSetsNotModified not expected.")); }, [&](const MTPDmessages_foundStickerSets &data) { - auto it = _searchSetsCache.find(_searchQuery); + auto it = _searchSetsCache.find(query); if (it == _searchSetsCache.cend()) { it = _searchSetsCache.emplace( - _searchQuery, + query, std::vector()).first; } for (const auto &setData : data.vsets().v) { @@ -1029,7 +1137,9 @@ void StickersListWidget::searchResultsDone( } it->second.push_back(set->id); } - showSearchResults(); + if (_searchNextQuery == query) { + showSearchResults(); + } }); } @@ -2512,6 +2622,9 @@ bool StickersListWidget::appendSet( uint64 setId, bool externalLayout, AppendSkip skip) { + if (_excludeSetId && setId == _excludeSetId) { + return false; + } const auto &sets = session().data().stickers().sets(); auto it = sets.find(setId); if (it == sets.cend() diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h index 175cb647ec..643720c122 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h @@ -57,6 +57,7 @@ struct FlatLabel; namespace ChatHelpers { extern const char kOptionUnlimitedRecentStickers[]; +[[nodiscard]] QVector SearchStickersLangCodes(); struct StickerIcon; enum class ValidateIconAnimations; @@ -84,6 +85,7 @@ struct StickersListDescriptor { std::vector customRecentList; const style::EmojiPan *st = nullptr; ComposeFeatures features; + uint64 excludeSetId = 0; }; class StickersListWidget final : public TabbedSelector::Inner { @@ -358,8 +360,21 @@ private: void cancelSetsSearch(); void showSearchResults(); - void searchResultsDone(const MTPmessages_FoundStickerSets &result); - void searchStickersResultsDone(const MTPmessages_FoundStickers &result); + void sendSearchSetsRequest(const QString &query); + void searchResultsDone( + const QString &query, + const MTPmessages_FoundStickerSets &result); + void requestSearchStickers( + const QString &query, + int offset, + bool requestSetsOnEmpty); + void searchStickersResultsDone( + const QString &query, + int requestedOffset, + bool requestSetsOnEmpty, + const MTPmessages_FoundStickers &result); + void loadMoreSearchStickers(); + void checkPaginateSearchStickers(int visibleTop, int visibleBottom); void refreshSearchRows(); void refreshSearchRows(const std::vector *cloudSets); void fillFilteredStickersRow(); @@ -407,6 +422,7 @@ private: Section _section = Section::Stickers; const bool _isMasks; const bool _isEffects; + const uint64 _excludeSetId = 0; base::Timer _updateItemsTimer; base::Timer _updateSetsTimer; @@ -449,6 +465,7 @@ private: rpl::variable _recentShownCount; std::map> _searchSetsCache; std::map> _searchStickersCache; + std::map _searchStickersNextOffset; std::vector> _searchIndex; base::Timer _searchRequestTimer; QString _searchQuery, _searchNextQuery; diff --git a/Telegram/SourceFiles/chat_helpers/stickers_lottie.cpp b/Telegram/SourceFiles/chat_helpers/stickers_lottie.cpp index e2b9cd80e7..9b129c3ca9 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_lottie.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_lottie.cpp @@ -349,7 +349,8 @@ not_null GenerateLocalSticker( not_null GenerateLocalTgsSticker( not_null session, - const QString &name) { + const QString &name, + bool useTextColor) { const auto cache = [&] { struct Session { base::weak_ptr session; @@ -370,7 +371,8 @@ not_null GenerateLocalTgsSticker( return &Map.back().cache; }(); - const auto i = cache->find(name); + const auto key = useTextColor ? (name + u"/:/1"_q) : name; + const auto i = cache->find(key); if (i != end(*cache)) { return i->second; } @@ -378,8 +380,11 @@ not_null GenerateLocalTgsSticker( const auto result = GenerateLocalSticker( session, u":/animations/"_q + name + u".tgs"_q); + if (useTextColor) { + result->overrideEmojiUsesTextColor(true); + } - cache->emplace(name, result); + cache->emplace(key, result); Ensures(result->sticker()->isLottie()); return result; diff --git a/Telegram/SourceFiles/chat_helpers/stickers_lottie.h b/Telegram/SourceFiles/chat_helpers/stickers_lottie.h index 4a9b3c39ba..8b67d3ebb1 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_lottie.h +++ b/Telegram/SourceFiles/chat_helpers/stickers_lottie.h @@ -137,6 +137,7 @@ bool PaintStickerThumbnailPath( [[nodiscard]] not_null GenerateLocalTgsSticker( not_null session, - const QString &name); + const QString &name, + bool useTextColor = false); } // namespace ChatHelpers diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_panel.cpp b/Telegram/SourceFiles/chat_helpers/tabbed_panel.cpp index 8f9cc10a20..f1de636cbd 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_panel.cpp +++ b/Telegram/SourceFiles/chat_helpers/tabbed_panel.cpp @@ -193,6 +193,10 @@ void TabbedPanel::setDesiredHeightValues( updateContentHeight(); } +void TabbedPanel::setShowAnimationOrigin(Ui::PanelAnimation::Origin origin) { + _showAnimationOrigin = origin; +} + void TabbedPanel::setDropDown(bool dropDown) { selector()->setDropDown(dropDown); _dropDown = dropDown; @@ -381,11 +385,12 @@ void TabbedPanel::startShowAnimation() { if (!_a_show.animating()) { auto image = grabForAnimation(); + const auto origin = _showAnimationOrigin.value_or(_dropDown + ? Ui::PanelAnimation::Origin::TopRight + : Ui::PanelAnimation::Origin::BottomRight); _showAnimation = std::make_unique( _selector->st().showAnimation, - (_dropDown - ? Ui::PanelAnimation::Origin::TopRight - : Ui::PanelAnimation::Origin::BottomRight)); + origin); auto inner = rect().marginsRemoved(st::emojiPanMargins); _showAnimation->setFinalImage( std::move(image), diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_panel.h b/Telegram/SourceFiles/chat_helpers/tabbed_panel.h index 29c9c43ed2..2e4c306851 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_panel.h +++ b/Telegram/SourceFiles/chat_helpers/tabbed_panel.h @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "ui/effects/animations.h" +#include "ui/effects/panel_animation.h" #include "ui/rp_widget.h" #include "ui/widgets/shadow.h" #include "base/timer.h" @@ -17,9 +18,6 @@ namespace Window { class SessionController; } // namespace Window -namespace Ui { -class PanelAnimation; -} // namespace Ui namespace ChatHelpers { @@ -57,6 +55,7 @@ public: int minHeight, int maxHeight); void setDropDown(bool dropDown); + void setShowAnimationOrigin(Ui::PanelAnimation::Origin origin); void hideFast(); bool hiding() const { @@ -122,6 +121,7 @@ private: bool _shouldFinishHide = false; bool _dropDown = false; + std::optional _showAnimationOrigin; bool _hiding = false; bool _hideAfterSlide = false; diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp index 65c0aaf7f3..778391388e 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp +++ b/Telegram/SourceFiles/chat_helpers/tabbed_selector.cpp @@ -381,6 +381,7 @@ TabbedSelector::TabbedSelector( , _show(std::move(descriptor.show)) , _level(descriptor.level) , _customTextColor(std::move(descriptor.customTextColor)) +, _excludeStickerSetId(descriptor.excludeStickerSetId) , _mode(descriptor.mode) , _panelRounding(Ui::PrepareCornerPixmaps(st::emojiPanRadius, _st.bg)) , _categoriesRounding( @@ -645,6 +646,7 @@ TabbedSelector::Tab TabbedSelector::createTab(SelectorTab type, int index) { .paused = paused, .st = &_st, .features = _features, + .excludeSetId = _excludeStickerSetId, }); } case SelectorTab::Gifs: { diff --git a/Telegram/SourceFiles/chat_helpers/tabbed_selector.h b/Telegram/SourceFiles/chat_helpers/tabbed_selector.h index 903da475b1..03c93bffd1 100644 --- a/Telegram/SourceFiles/chat_helpers/tabbed_selector.h +++ b/Telegram/SourceFiles/chat_helpers/tabbed_selector.h @@ -100,6 +100,7 @@ struct TabbedSelectorDescriptor { TabbedSelectorMode mode = TabbedSelectorMode::Full; Fn customTextColor; ComposeFeatures features; + uint64 excludeStickerSetId = 0; }; enum class TabbedSearchType { @@ -295,6 +296,7 @@ private: const std::shared_ptr _show; const PauseReason _level = {}; const Fn _customTextColor; + const uint64 _excludeStickerSetId = 0; Ui::Controls::SwipeBackResult _swipeBackData; diff --git a/Telegram/SourceFiles/core/application.cpp b/Telegram/SourceFiles/core/application.cpp index b53bf04780..951b54e051 100644 --- a/Telegram/SourceFiles/core/application.cpp +++ b/Telegram/SourceFiles/core/application.cpp @@ -27,6 +27,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "core/sandbox.h" #include "core/local_url_handlers.h" #include "core/launcher.h" +#include "core/proxy_rotation_manager.h" #include "core/ui_integration.h" #include "chat_helpers/emoji_keywords.h" #include "chat_helpers/stickers_emoji_image_loader.h" @@ -151,6 +152,7 @@ struct Application::Private { base::Timer quitTimer; UiIntegration uiIntegration; Settings settings; + std::unique_ptr proxyRotation; }; Application::Application() @@ -178,6 +180,7 @@ Application::Application() , _setupEmailLock(false) , _autoLockTimer([=] { checkAutoLock(); }) { Ui::Integration::Set(&_private->uiIntegration); + _private->proxyRotation = std::make_unique(); _platformIntegration->init(); @@ -239,6 +242,7 @@ Application::~Application() { // Domain::finish() and there is a violation on Ensures(started()). closeAdditionalWindows(); + _private->proxyRotation = nullptr; _domain->finish(); Local::finish(); @@ -843,6 +847,17 @@ void Application::setCurrentProxy( refreshGlobalProxy(); _proxyChanges.fire({ was, now }); my.connectionTypeChangesNotify(); + proxyRotationSettingsChanged(); +} + +void Application::proxyRotationSettingsChanged() { + _private->proxyRotation->settingsChanged(); +} + +void Application::checkProxyRotation( + not_null account, + int32 state) { + _private->proxyRotation->handleConnectionStateChanged(account, state); } auto Application::proxyChanges() const -> rpl::producer { diff --git a/Telegram/SourceFiles/core/application.h b/Telegram/SourceFiles/core/application.h index 9bb59ce875..927dff491e 100644 --- a/Telegram/SourceFiles/core/application.h +++ b/Telegram/SourceFiles/core/application.h @@ -219,6 +219,8 @@ public: void setCurrentProxy( const MTP::ProxyData &proxy, MTP::ProxyData::Settings settings); + void proxyRotationSettingsChanged(); + void checkProxyRotation(not_null account, int32 state); [[nodiscard]] rpl::producer proxyChanges() const; void badMtprotoConfigurationError(); diff --git a/Telegram/SourceFiles/core/core_settings_proxy.cpp b/Telegram/SourceFiles/core/core_settings_proxy.cpp index 1807aa2efe..495bb0b846 100644 --- a/Telegram/SourceFiles/core/core_settings_proxy.cpp +++ b/Telegram/SourceFiles/core/core_settings_proxy.cpp @@ -10,6 +10,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/platform/base_platform_info.h" #include "storage/serialize_common.h" +#include + namespace Core { namespace { @@ -88,6 +90,22 @@ namespace { return result; } +std::vector NormalizeProxyRotationPreferredIndices( + std::vector indices, + int listSize) { + auto filtered = std::vector(); + filtered.reserve(indices.size()); + for (const auto index : indices) { + if (index < 0 + || index >= listSize + || ranges::contains(filtered, index)) { + continue; + } + filtered.push_back(index); + } + return filtered; +} + } // namespace SettingsProxy::SettingsProxy() @@ -107,7 +125,8 @@ QByteArray SettingsProxy::serialize() const { serializedList, 0, ranges::plus(), - &Serialize::bytearraySize); + &Serialize::bytearraySize) + + (4 + int(_proxyRotationPreferredIndices.size())) * sizeof(qint32); auto stream = Serialize::ByteArrayWriter(size); stream << qint32(_tryIPv6 ? 1 : 0) @@ -118,6 +137,14 @@ QByteArray SettingsProxy::serialize() const { for (const auto &i : serializedList) { stream << i; } + stream + << qint32(_checkIpWarningShown ? 1 : 0) + << qint32(_proxyRotationEnabled ? 1 : 0) + << qint32(_proxyRotationTimeout) + << qint32(_proxyRotationPreferredIndices.size()); + for (const auto index : _proxyRotationPreferredIndices) { + stream << qint32(index); + } return std::move(stream).result(); } @@ -133,6 +160,7 @@ bool SettingsProxy::setFromSerialized(const QByteArray &serialized) { auto settings = ProxySettingsToInt(_settings); auto listCount = qint32(_list.size()); auto selectedProxy = QByteArray(); + auto list = std::vector(); if (!stream.atEnd()) { stream @@ -142,10 +170,43 @@ bool SettingsProxy::setFromSerialized(const QByteArray &serialized) { >> selectedProxy >> listCount; if (stream.ok()) { + if (listCount < 0) { + return false; + } + list.reserve(listCount); for (auto i = 0; i != listCount; ++i) { QByteArray data; stream >> data; - _list.push_back(DeserializeProxyData(data)); + list.push_back(DeserializeProxyData(data)); + } + } + } + + auto checkIpWarningShown = qint32(0); + if (!stream.atEnd()) { + stream >> checkIpWarningShown; + } + auto proxyRotationEnabled = qint32(_proxyRotationEnabled ? 1 : 0); + if (!stream.atEnd()) { + stream >> proxyRotationEnabled; + } + auto proxyRotationTimeout = qint32(_proxyRotationTimeout); + if (!stream.atEnd()) { + stream >> proxyRotationTimeout; + } + auto preferredCount = qint32(0); + auto preferredIndices = std::vector(); + if (!stream.atEnd()) { + stream >> preferredCount; + if (stream.ok()) { + if (preferredCount < 0) { + return false; + } + preferredIndices.reserve(preferredCount); + for (auto i = 0; i != preferredCount; ++i) { + auto index = qint32(0); + stream >> index; + preferredIndices.push_back(index); } } } @@ -158,8 +219,13 @@ bool SettingsProxy::setFromSerialized(const QByteArray &serialized) { _tryIPv6 = (tryIPv6 == 1); _useProxyForCalls = (useProxyForCalls == 1); + _checkIpWarningShown = (checkIpWarningShown == 1); + _proxyRotationEnabled = (proxyRotationEnabled == 1); + setProxyRotationTimeout(proxyRotationTimeout); _settings = IntToProxySettings(settings); _selected = DeserializeProxyData(selectedProxy); + _list = std::move(list); + setProxyRotationPreferredIndices(std::move(preferredIndices)); return true; } @@ -176,6 +242,40 @@ bool SettingsProxy::isDisabled() const { return _settings == MTP::ProxyData::Settings::Disabled; } +bool SettingsProxy::checkIpWarningShown() const { + return _checkIpWarningShown; +} + +void SettingsProxy::setCheckIpWarningShown(bool value) { + _checkIpWarningShown = value; +} + +const std::vector &SettingsProxy::proxyRotationPreferredIndices() const { + return _proxyRotationPreferredIndices; +} + +void SettingsProxy::setProxyRotationPreferredIndices(std::vector value) { + _proxyRotationPreferredIndices = NormalizeProxyRotationPreferredIndices( + std::move(value), + int(_list.size())); +} + +bool SettingsProxy::promoteProxyRotationPreferredIndex(int index) { + if (index < 0 || index >= int(_list.size())) { + return false; + } + auto &indices = _proxyRotationPreferredIndices; + const auto i = ranges::find(indices, index); + if (i == begin(indices)) { + return false; + } else if (i != end(indices)) { + std::rotate(begin(indices), i, std::next(i)); + } else { + indices.insert(begin(indices), index); + } + return true; +} + bool SettingsProxy::tryIPv6() const { return _tryIPv6; } @@ -192,6 +292,24 @@ void SettingsProxy::setUseProxyForCalls(bool value) { _useProxyForCalls = value; } +bool SettingsProxy::proxyRotationEnabled() const { + return _proxyRotationEnabled; +} + +void SettingsProxy::setProxyRotationEnabled(bool value) { + _proxyRotationEnabled = value; +} + +int SettingsProxy::proxyRotationTimeout() const { + return _proxyRotationTimeout; +} + +void SettingsProxy::setProxyRotationTimeout(int value) { + _proxyRotationTimeout = (value > 0) + ? value + : kDefaultProxyRotationTimeout; +} + MTP::ProxyData::Settings SettingsProxy::settings() const { return _settings; } @@ -216,6 +334,62 @@ std::vector &SettingsProxy::list() { return _list; } +void SettingsProxy::setList(std::vector value) { + _list = std::move(value); + _proxyRotationPreferredIndices.clear(); +} + +void SettingsProxy::addToList(MTP::ProxyData value) { + _list.push_back(std::move(value)); +} + +void SettingsProxy::insertToList(int index, MTP::ProxyData value) { + index = std::clamp(index, 0, int(_list.size())); + for (auto &existing : _proxyRotationPreferredIndices) { + if (existing >= index) { + ++existing; + } + } + _list.insert(begin(_list) + index, std::move(value)); +} + +bool SettingsProxy::removeFromList(const MTP::ProxyData &value) { + const auto i = ranges::find(_list, value); + if (i == end(_list)) { + return false; + } + const auto index = int(i - begin(_list)); + _list.erase(i); + for (auto &existing : _proxyRotationPreferredIndices) { + if (existing > index) { + --existing; + } + } + _proxyRotationPreferredIndices.erase( + std::remove( + begin(_proxyRotationPreferredIndices), + end(_proxyRotationPreferredIndices), + index), + end(_proxyRotationPreferredIndices)); + return true; +} + +bool SettingsProxy::replaceInList( + const MTP::ProxyData &was, + MTP::ProxyData value) { + const auto i = ranges::find(_list, was); + if (i == end(_list)) { + return false; + } + *i = std::move(value); + return true; +} + +int SettingsProxy::indexInList(const MTP::ProxyData &value) const { + const auto i = ranges::find(_list, value); + return (i == end(_list)) ? -1 : int(i - begin(_list)); +} + rpl::producer<> SettingsProxy::connectionTypeValue() const { return _connectionTypeChanges.events_starting_with({}); } diff --git a/Telegram/SourceFiles/core/core_settings_proxy.h b/Telegram/SourceFiles/core/core_settings_proxy.h index bd709c230a..1f2ec5c516 100644 --- a/Telegram/SourceFiles/core/core_settings_proxy.h +++ b/Telegram/SourceFiles/core/core_settings_proxy.h @@ -9,10 +9,21 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "mtproto/mtproto_proxy_data.h" +#include + namespace Core { class SettingsProxy final { public: + static constexpr auto kProxyRotationTimeouts = std::array{ + 5, + 10, + 15, + 30, + 60, + }; + static constexpr auto kDefaultProxyRotationTimeout = 10; + SettingsProxy(); [[nodiscard]] bool isEnabled() const; @@ -29,14 +40,35 @@ public: [[nodiscard]] bool useProxyForCalls() const; void setUseProxyForCalls(bool value); + [[nodiscard]] bool proxyRotationEnabled() const; + void setProxyRotationEnabled(bool value); + + [[nodiscard]] int proxyRotationTimeout() const; + void setProxyRotationTimeout(int value); + [[nodiscard]] MTP::ProxyData::Settings settings() const; void setSettings(MTP::ProxyData::Settings value); [[nodiscard]] MTP::ProxyData selected() const; void setSelected(MTP::ProxyData value); + [[nodiscard]] bool checkIpWarningShown() const; + void setCheckIpWarningShown(bool value); + + [[nodiscard]] const std::vector &proxyRotationPreferredIndices() const; + void setProxyRotationPreferredIndices(std::vector value); + [[nodiscard]] bool promoteProxyRotationPreferredIndex(int index); + [[nodiscard]] const std::vector &list() const; [[nodiscard]] std::vector &list(); + void setList(std::vector value); + void addToList(MTP::ProxyData value); + void insertToList(int index, MTP::ProxyData value); + [[nodiscard]] bool removeFromList(const MTP::ProxyData &value); + [[nodiscard]] bool replaceInList( + const MTP::ProxyData &was, + MTP::ProxyData value); + [[nodiscard]] int indexInList(const MTP::ProxyData &value) const; [[nodiscard]] QByteArray serialize() const; bool setFromSerialized(const QByteArray &serialized); @@ -44,13 +76,16 @@ public: private: bool _tryIPv6 = false; bool _useProxyForCalls = false; + bool _proxyRotationEnabled = false; + bool _checkIpWarningShown = false; + int _proxyRotationTimeout = kDefaultProxyRotationTimeout; MTP::ProxyData::Settings _settings = MTP::ProxyData::Settings::System; MTP::ProxyData _selected; std::vector _list; + std::vector _proxyRotationPreferredIndices; rpl::event_stream<> _connectionTypeChanges; }; } // namespace Core - diff --git a/Telegram/SourceFiles/core/launcher.cpp b/Telegram/SourceFiles/core/launcher.cpp index 2b109d6cf9..62077582ec 100644 --- a/Telegram/SourceFiles/core/launcher.cpp +++ b/Telegram/SourceFiles/core/launcher.cpp @@ -119,7 +119,7 @@ void ComputeDebugMode() { auto file = QFile(debugModeSettingPath); if (file.exists() && file.open(QIODevice::ReadOnly)) { Logs::SetDebugEnabled(file.read(1) != "0"); -#if defined _DEBUG +#if defined _DEBUG && !defined Q_OS_MAC } else { Logs::SetDebugEnabled(true); #endif diff --git a/Telegram/SourceFiles/core/proxy_rotation_manager.cpp b/Telegram/SourceFiles/core/proxy_rotation_manager.cpp new file mode 100644 index 0000000000..a643f0aee6 --- /dev/null +++ b/Telegram/SourceFiles/core/proxy_rotation_manager.cpp @@ -0,0 +1,359 @@ +/* +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 "core/proxy_rotation_manager.h" + +#include "core/application.h" +#include "core/core_settings.h" +#include "main/main_account.h" +#include "main/main_domain.h" +#include "mtproto/facade.h" + +#include + +namespace Core { +namespace { + +constexpr auto kProxyRotationCheckInterval = 2 * crl::time(1000); +constexpr auto kProxyRotationCheckLifetime = 20 * crl::time(1000); +constexpr auto kProxyRotationMaxActiveChecks = 10; + +} // namespace + +ProxyRotationManager::ProxyRotationManager() +: _checkTimer([=] { runChecks(); }) +, _switchTimer([=] { switchTimerDone(); }) { + App().domain().accountsChanges( + ) | rpl::on_next([=] { + stopChecking(); + reevaluate(); + }, _lifetime); +} + +void ProxyRotationManager::settingsChanged() { + stopChecking(); + pruneRemovedEntries(); + reevaluate(); +} + +void ProxyRotationManager::handleConnectionStateChanged( + not_null account, + int32 state) { + (void)account; + (void)state; + reevaluate(); +} + +bool ProxyRotationManager::shouldObserve() const { + const auto &settings = App().settings().proxy(); + return settings.isEnabled() + && settings.selected() + && settings.proxyRotationEnabled() + && (settings.list().size() > 1); +} + +std::vector> ProxyRotationManager::productionAccounts() const { + auto result = std::vector>(); + for (const auto &entry : App().domain().accounts()) { + const auto account = entry.account.get(); + if (!account->sessionExists() || account->mtp().isTestMode()) { + continue; + } + result.push_back(account); + } + return result; +} + +not_null ProxyRotationManager::accountForChecks() const { + if (App().someSessionExists() + && App().activeAccount().sessionExists() + && !App().activeAccount().mtp().isTestMode()) { + return &App().activeAccount(); + } + const auto accounts = productionAccounts(); + Expects(!accounts.empty()); + return accounts.front(); +} + +auto ProxyRotationManager::find( + const MTP::ProxyData &proxy) -> Entry* { + const auto i = ranges::find( + _entries, + proxy, + [](const Entry &entry) { return entry.proxy; }); + return (i == end(_entries)) ? nullptr : &*i; +} + +auto ProxyRotationManager::ensure( + const MTP::ProxyData &proxy) -> Entry& { + if (const auto entry = find(proxy)) { + return *entry; + } + _entries.push_back({ .proxy = proxy }); + return _entries.back(); +} + +void ProxyRotationManager::reevaluate() { + if (!shouldObserve()) { + stopChecking(); + return; + } + const auto accounts = productionAccounts(); + if (accounts.empty()) { + stopChecking(); + return; + } + const auto stateProj = [](not_null account) { + return account->mtp().dcstate(); + }; + if (ranges::contains(accounts, MTP::ConnectedState, stateProj)) { + stopChecking(); + return; + } + startChecking(); +} + +void ProxyRotationManager::startChecking() { + if (_checking) { + return; + } + _checking = true; + _waitingToSwitch = false; + _switchStartedAt = crl::now(); + updateProbeOrder(); + runChecks(); + const auto timeout = App().settings().proxy().proxyRotationTimeout(); + _switchTimer.callOnce(timeout * crl::time(1000)); +} + +void ProxyRotationManager::stopChecking() { + _checkTimer.cancel(); + _switchTimer.cancel(); + _checking = false; + _waitingToSwitch = false; + _switchStartedAt = 0; + _probeOrder.clear(); + _nextCheckIndex = 0; + clearPendingChecks(); +} + +void ProxyRotationManager::pruneRemovedEntries() { + const auto &settings = App().settings().proxy(); + _entries.erase( + std::remove_if(begin(_entries), end(_entries), [&](const Entry &entry) { + return (settings.indexInList(entry.proxy) < 0); + }), + end(_entries)); +} + +void ProxyRotationManager::updateProbeOrder() { + const auto &settings = App().settings().proxy(); + const auto currentIndex = settings.indexInList(settings.selected()); + _probeOrder.clear(); + _probeOrder.reserve(settings.list().size()); + for (const auto index : settings.proxyRotationPreferredIndices()) { + if (index == currentIndex) { + continue; + } + _probeOrder.push_back(index); + } + for (auto i = 0, count = int(settings.list().size()); i != count; ++i) { + if (i == currentIndex || ranges::contains(_probeOrder, i)) { + continue; + } + _probeOrder.push_back(i); + } + _nextCheckIndex = 0; +} + +void ProxyRotationManager::continueChecking(crl::time delay) { + if (!_checking) { + return; + } + if (_checkTimer.isActive()) { + _checkTimer.cancel(); + } + _checkTimer.callOnce(delay); +} + +void ProxyRotationManager::runChecks() { + if (!_checking) { + return; + } + if (!shouldObserve()) { + stopChecking(); + return; + } + const auto accounts = productionAccounts(); + if (accounts.empty() + || ranges::contains( + accounts, + MTP::ConnectedState, + [](not_null account) { + return account->mtp().dcstate(); + })) { + stopChecking(); + return; + } + pruneExpiredChecks(); + startNextCheck(); + continueChecking(kProxyRotationCheckInterval); +} + +void ProxyRotationManager::pruneExpiredChecks() { + const auto now = crl::now(); + for (auto &entry : _entries) { + if (!entry.checking + || (now - entry.startedAt < kProxyRotationCheckLifetime)) { + continue; + } + MTP::ResetProxyCheckers(entry.v4, entry.v6); + entry.checking = false; + entry.startedAt = 0; + } +} + +void ProxyRotationManager::startNextCheck() { + if (_probeOrder.empty()) { + return; + } + if (ranges::count(_entries, true, &Entry::checking) + >= kProxyRotationMaxActiveChecks) { + return; + } + const auto &settings = App().settings().proxy(); + auto attemptsLeft = int(_probeOrder.size()); + while (attemptsLeft-- > 0) { + if (_nextCheckIndex >= int(_probeOrder.size())) { + _nextCheckIndex = 0; + } + const auto listIndex = _probeOrder[_nextCheckIndex++]; + if (listIndex < 0 || listIndex >= int(settings.list().size())) { + continue; + } + const auto &proxy = settings.list()[listIndex]; + auto &entry = ensure(proxy); + if (entry.checking) { + continue; + } + entry.checking = true; + entry.startedAt = crl::now(); + MTP::StartProxyCheck( + &accountForChecks()->mtp(), + proxy, + settings.tryIPv6(), + entry.v4, + entry.v6, + [=](MTP::details::AbstractConnection *raw, int ping) { + checkDone(proxy, raw, ping); + }, + [=](MTP::details::AbstractConnection *raw) { + checkFailed(proxy, raw); + }); + break; + } +} + +void ProxyRotationManager::switchTimerDone() { + if (!_checking || !shouldSwitchToAvailable()) { + return; + } + _waitingToSwitch = !switchToAvailable(); +} + +void ProxyRotationManager::clearPendingChecks() { + for (auto &entry : _entries) { + MTP::ResetProxyCheckers(entry.v4, entry.v6); + entry.checking = false; + entry.startedAt = 0; + } +} + +void ProxyRotationManager::checkDone( + const MTP::ProxyData &proxy, + not_null raw, + int ping) { + const auto entry = find(proxy); + if (!entry + || !entry->checking + || ((entry->v4.get() != raw) && (entry->v6.get() != raw))) { + return; + } + MTP::DropProxyChecker(entry->v4, entry->v6, raw); + MTP::ResetProxyCheckers(entry->v4, entry->v6); + entry->checking = false; + entry->startedAt = 0; + entry->availableAt = crl::now(); + const auto proxySettings = &App().settings().proxy(); + if (const auto index = proxySettings->indexInList(proxy); index >= 0) { + if (proxySettings->promoteProxyRotationPreferredIndex(index)) { + App().saveSettingsDelayed(); + } + } + updateProbeOrder(); + if (_waitingToSwitch && shouldSwitchToAvailable()) { + _waitingToSwitch = !switchToAvailable(); + } +} + +void ProxyRotationManager::checkFailed( + const MTP::ProxyData &proxy, + not_null raw) { + const auto entry = find(proxy); + if (!entry + || !entry->checking + || ((entry->v4.get() != raw) && (entry->v6.get() != raw))) { + return; + } + MTP::DropProxyChecker(entry->v4, entry->v6, raw); + if (MTP::HasProxyCheckers(entry->v4, entry->v6)) { + return; + } + entry->checking = false; + entry->startedAt = 0; +} + +bool ProxyRotationManager::switchToAvailable() { + if (!_checking) { + return false; + } + const auto &settings = App().settings().proxy(); + for (const auto index : _probeOrder) { + if (index < 0 || index >= int(settings.list().size())) { + continue; + } + const auto &proxy = settings.list()[index]; + const auto entry = find(proxy); + if (!entry || entry->checking || !entry->availableAt) { + continue; + } + if (entry->availableAt < _switchStartedAt) { + continue; + } + _waitingToSwitch = false; + App().setCurrentProxy(proxy, MTP::ProxyData::Settings::Enabled); + App().saveSettingsDelayed(); + return true; + } + return false; +} + +bool ProxyRotationManager::shouldSwitchToAvailable() const { + if (!_checking || !shouldObserve()) { + return false; + } + const auto accounts = productionAccounts(); + return !accounts.empty() + && !ranges::contains( + accounts, + MTP::ConnectedState, + [](not_null account) { + return account->mtp().dcstate(); + }); +} + +} // namespace Core diff --git a/Telegram/SourceFiles/core/proxy_rotation_manager.h b/Telegram/SourceFiles/core/proxy_rotation_manager.h new file mode 100644 index 0000000000..c1fee27696 --- /dev/null +++ b/Telegram/SourceFiles/core/proxy_rotation_manager.h @@ -0,0 +1,80 @@ +/* +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/timer.h" +#include "mtproto/proxy_check.h" + +#include +#include + +namespace Main { +class Account; +} // namespace Main + +namespace Core { + +class ProxyRotationManager final { +public: + ProxyRotationManager(); + + void settingsChanged(); + void handleConnectionStateChanged( + not_null account, + int32 state); + +private: + struct Entry { + MTP::ProxyData proxy; + MTP::ProxyCheckConnection v4; + MTP::ProxyCheckConnection v6; + bool checking = false; + crl::time startedAt = 0; + crl::time availableAt = 0; + }; + + [[nodiscard]] bool shouldObserve() const; + [[nodiscard]] std::vector> productionAccounts() const; + [[nodiscard]] not_null accountForChecks() const; + [[nodiscard]] Entry *find(const MTP::ProxyData &proxy); + [[nodiscard]] Entry &ensure(const MTP::ProxyData &proxy); + + void reevaluate(); + void startChecking(); + void stopChecking(); + void pruneRemovedEntries(); + void updateProbeOrder(); + void continueChecking(crl::time delay); + void runChecks(); + void pruneExpiredChecks(); + void startNextCheck(); + void switchTimerDone(); + void clearPendingChecks(); + void checkDone( + const MTP::ProxyData &proxy, + not_null raw, + int ping); + void checkFailed( + const MTP::ProxyData &proxy, + not_null raw); + [[nodiscard]] bool switchToAvailable(); + [[nodiscard]] bool shouldSwitchToAvailable() const; + + base::Timer _checkTimer; + base::Timer _switchTimer; + std::vector _entries; + std::vector _probeOrder; + int _nextCheckIndex = 0; + bool _checking = false; + bool _waitingToSwitch = false; + crl::time _switchStartedAt = 0; + rpl::lifetime _lifetime; + +}; + +} // namespace Core diff --git a/Telegram/SourceFiles/core/sandbox.cpp b/Telegram/SourceFiles/core/sandbox.cpp index 81ea4e4906..40bba594f8 100644 --- a/Telegram/SourceFiles/core/sandbox.cpp +++ b/Telegram/SourceFiles/core/sandbox.cpp @@ -30,6 +30,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/ui_utility.h" #include "ui/effects/animations.h" +#ifdef Q_OS_MAC +#include "platform/mac/global_menu_mac.h" +#endif // Q_OS_MAC + #include #include #include @@ -54,6 +58,9 @@ bool Sandbox::QuitOnStartRequested = false; Sandbox::Sandbox(int &argc, char **argv) : QApplication(argc, argv) , _mainThreadId(QThread::currentThreadId()) { +#ifdef Q_OS_MAC + Platform::CreateGlobalMenu(); +#endif // Q_OS_MAC } int Sandbox::start() { @@ -222,7 +229,11 @@ void Sandbox::setupScreenScale() { LOG(("ScreenScale: %1").arg(cScreenScale())); } -Sandbox::~Sandbox() = default; +Sandbox::~Sandbox() { +#ifdef Q_OS_MAC + Platform::DestroyGlobalMenu(); +#endif // Q_OS_MAC +} bool Sandbox::event(QEvent *e) { if (e->type() == QEvent::Quit) { diff --git a/Telegram/SourceFiles/core/version.h b/Telegram/SourceFiles/core/version.h index ba20f42354..314c9dae37 100644 --- a/Telegram/SourceFiles/core/version.h +++ b/Telegram/SourceFiles/core/version.h @@ -22,7 +22,7 @@ constexpr auto AppId = "{53F49750-6209-4FBF-9CA8-7A333C87D666}"_cs; constexpr auto AppNameOld = "AyuGram for Windows"_cs; constexpr auto AppName = "AyuGram Desktop"_cs; constexpr auto AppFile = "AyuGram"_cs; -constexpr auto AppVersion = 6007005; -constexpr auto AppVersionStr = "6.7.5"; +constexpr auto AppVersion = 6007008; +constexpr auto AppVersionStr = "6.7.8"; constexpr auto AppBetaVersion = false; constexpr auto AppAlphaVersion = TDESKTOP_ALPHA_VERSION; diff --git a/Telegram/SourceFiles/data/components/credits.h b/Telegram/SourceFiles/data/components/credits.h index c7a7325c4f..c20323e6f6 100644 --- a/Telegram/SourceFiles/data/components/credits.h +++ b/Telegram/SourceFiles/data/components/credits.h @@ -66,7 +66,6 @@ private: rpl::variable _nonLockedBalance; rpl::event_stream<> _loadedChanges; crl::time _lastLoaded = 0; - float64 _rate = 0.; rpl::variable _tonBalance; rpl::event_stream<> _tonLoadedChanges; diff --git a/Telegram/SourceFiles/data/components/top_peers.h b/Telegram/SourceFiles/data/components/top_peers.h index 7f834ad84f..6a247d488a 100644 --- a/Telegram/SourceFiles/data/components/top_peers.h +++ b/Telegram/SourceFiles/data/components/top_peers.h @@ -57,7 +57,6 @@ private: mtpRequestId _requestId = 0; bool _disabled = false; - bool _received = false; }; diff --git a/Telegram/SourceFiles/data/data_document.cpp b/Telegram/SourceFiles/data/data_document.cpp index f5e88a0dc3..d36b6d94a0 100644 --- a/Telegram/SourceFiles/data/data_document.cpp +++ b/Telegram/SourceFiles/data/data_document.cpp @@ -88,6 +88,16 @@ void UpdateStickerSetIdentifier( }); } +[[nodiscard]] int ResolveAttributeVsTranscodeQuality( + int attributesQuality, + int transcodeMax) { + return (transcodeMax > 0 + && (attributesQuality < transcodeMax + || attributesQuality > transcodeMax * 1.5)) + ? transcodeMax + : attributesQuality; +} + } // namespace QString FileNameUnsafe( @@ -547,8 +557,7 @@ void DocumentData::setVideoQualities( return document->isVideoFile() && !document->dimensions.isEmpty() && !document->inappPlaybackFailed() - && document->useStreamingLoader() - && document->canBeStreamed(); + && document->useStreamingLoader(); }; ranges::sort( qualities, @@ -578,18 +587,58 @@ void DocumentData::setVideoQualities( } qualities.erase(qualities.begin() + count, qualities.end()); if (!qualities.empty()) { - if (const auto mine = resolveVideoQuality()) { - if (mine > qualities.front()->resolveVideoQuality()) { - qualities.insert(begin(qualities), this); + auto transcodeMax = 0; + for (const auto &quality : qualities) { + const auto qres = quality->resolveVideoQuality(); + if (qres > transcodeMax) { + transcodeMax = qres; } } + const auto attributesSize = isVideoFile() ? dimensions : QSize(); + const auto attributesQuality = attributesSize.isEmpty() + ? 0 + : std::min(attributesSize.width(), attributesSize.height()); + auto mine = ResolveAttributeVsTranscodeQuality( + attributesQuality, + transcodeMax); + if (mine) { + qualities.insert(begin(qualities), this); + } } data->qualities = std::move(qualities); } int DocumentData::resolveVideoQuality() const { - const auto size = isVideoFile() ? dimensions : QSize(); - return size.isEmpty() ? 0 : std::min(size.width(), size.height()); + if (const auto data = video()) { + if (!data->realVideoSize.isEmpty()) { + const auto size = data->realVideoSize; + return std::min(size.width(), size.height()); + } + const auto attributesSize = isVideoFile() ? dimensions : QSize(); + const auto attributesQuality = attributesSize.isEmpty() + ? 0 + : std::min(attributesSize.width(), attributesSize.height()); + if (!data->qualities.empty()) { + auto transcodeMax = 0; + for (const auto &quality : data->qualities) { + if (quality != this) { + const auto qres = quality->resolveVideoQuality(); + if (qres > transcodeMax) { + transcodeMax = qres; + } + } + } + if (transcodeMax > 0) { + return ResolveAttributeVsTranscodeQuality( + attributesQuality, + transcodeMax); + } + } + } + const auto attributesSize = isVideoFile() ? dimensions : QSize(); + return attributesSize.isEmpty() + ? 0 + : std::min(attributesSize.width(), attributesSize.height()); } auto DocumentData::resolveQualities(HistoryItem *context) const @@ -611,19 +660,28 @@ not_null DocumentData::chooseQuality( return this; } const auto height = int(request.height); - auto closest = this; - auto closestAbs = std::abs(height - resolveVideoQuality()); - auto closestSize = size; + if (request.original) { + return this; + } + + auto closest = (DocumentData*)nullptr; + auto closestAbs = -1; + auto closestSize = -1; + for (const auto &quality : list) { - const auto abs = std::abs(height - quality->resolveVideoQuality()); - if (abs < closestAbs - || (abs == closestAbs && quality->size < closestSize)) { + const auto qres = quality->resolveVideoQuality(); + const auto abs = std::abs(height - qres); + if (!closest + || abs < closestAbs + || (abs == closestAbs && (quality->size < closestSize + || (closest == this && quality != this)))) { closest = quality; closestAbs = abs; closestSize = quality->size; } } - return closest; + + return closest ? closest : this; } void DocumentData::validateLottieSticker() { diff --git a/Telegram/SourceFiles/data/data_document.h b/Telegram/SourceFiles/data/data_document.h index 883d7bea9a..884884bab0 100644 --- a/Telegram/SourceFiles/data/data_document.h +++ b/Telegram/SourceFiles/data/data_document.h @@ -99,6 +99,7 @@ struct VoiceData : public DocumentAdditionalData { struct VideoData : public DocumentAdditionalData { QString codec; std::vector> qualities; + QSize realVideoSize; }; using RoundData = VoiceData; diff --git a/Telegram/SourceFiles/data/data_document_media.cpp b/Telegram/SourceFiles/data/data_document_media.cpp index d06da8cf06..f8c382ea64 100644 --- a/Telegram/SourceFiles/data/data_document_media.cpp +++ b/Telegram/SourceFiles/data/data_document_media.cpp @@ -181,7 +181,7 @@ Image *DocumentMedia::goodThumbnail() const { } void DocumentMedia::setGoodThumbnail(QImage thumbnail) { - if (!(_flags & Flag::GoodThumbnailWanted)) { + if (!(_flags & Flag::GoodThumbnailWanted) || thumbnail.isNull()) { return; } _goodThumbnail = std::make_unique(std::move(thumbnail)); @@ -285,7 +285,10 @@ void DocumentMedia::checkStickerLarge() { void DocumentMedia::automaticLoad( Data::FileOrigin origin, const HistoryItem *item) { - if (_owner->status != FileReady || loaded() || _owner->cancelled()) { + if (_owner->status != FileReady + || loaded() + || _owner->uploading() + || _owner->cancelled()) { return; } else if (!item && !_owner->sticker() && !_owner->isAnimation()) { return; diff --git a/Telegram/SourceFiles/data/data_media_types.cpp b/Telegram/SourceFiles/data/data_media_types.cpp index c596e2fa31..968e3d4b95 100644 --- a/Telegram/SourceFiles/data/data_media_types.cpp +++ b/Telegram/SourceFiles/data/data_media_types.cpp @@ -101,11 +101,11 @@ struct AlbumCounts { }; [[nodiscard]] TextWithEntities WithCaptionNotificationText( - const QString &attachType, + TextWithEntities attachType, const TextWithEntities &caption, bool hasMiniImages = false) { if (caption.text.isEmpty()) { - return Ui::Text::Colorized(attachType); + return Ui::Text::Colorized(std::move(attachType)); } auto wrapped = st::wrap_rtl(caption); return hasMiniImages @@ -116,13 +116,23 @@ struct AlbumCounts { tr::lng_dialogs_text_media_wrapped( tr::now, lt_media, - Ui::Text::Colorized(attachType), + Ui::Text::Colorized(std::move(attachType)), tr::marked), lt_caption, wrapped, tr::marked); } +[[nodiscard]] TextWithEntities WithCaptionNotificationText( + const QString &attachType, + const TextWithEntities &caption, + bool hasMiniImages = false) { + return WithCaptionNotificationText( + Ui::Text::WithEntities(attachType), + caption, + hasMiniImages); +} + [[nodiscard]] QImage PreparePreviewImage( not_null image, ImageRoundRadius radius, @@ -1209,27 +1219,35 @@ ItemPreview MediaFile::toPreview(ToPreviewOptions options) const { } } } - const auto type = [&] { + const auto type = [&]() -> TextWithEntities { using namespace Ui::Text; if (_document->isVideoMessage()) { - return (item->media() && item->media()->ttlSeconds()) + return WithEntities((item->media() && item->media()->ttlSeconds()) ? tr::lng_in_dlg_video_message_ttl(tr::now) - : tr::lng_in_dlg_video_message(tr::now); + : tr::lng_in_dlg_video_message(tr::now)); } else if (_document->isAnimation()) { - return u"GIF"_q; + return WithEntities(u"GIF"_q); } else if (_document->isVideoFile()) { - return tr::lng_in_dlg_video(tr::now); + return WithEntities(tr::lng_in_dlg_video(tr::now)); } else if (_document->isVoiceMessage()) { - return (item->media() && item->media()->ttlSeconds()) - ? tr::lng_in_dlg_voice_message_ttl(tr::now) - : tr::lng_in_dlg_audio(tr::now); + if (item->media() && item->media()->ttlSeconds()) { + return WithEntities( + tr::lng_in_dlg_voice_message_ttl(tr::now)); + } else if (item->isUnreadMedia()) { + return tr::lng_in_dlg_audio_unread( + tr::now, + lt_emoji, + IconEmoji(&st::dialogsUnreadMediaDotEmoji), + tr::rich); + } + return WithEntities(tr::lng_in_dlg_audio(tr::now)); } else if (const auto name = FormatSongNameFor(_document).string(); !name.isEmpty()) { - return name; + return WithEntities(name); } else if (_document->isAudioFile()) { - return tr::lng_in_dlg_audio_file(tr::now); + return WithEntities(tr::lng_in_dlg_audio_file(tr::now)); } - return tr::lng_in_dlg_file(tr::now); + return WithEntities(tr::lng_in_dlg_file(tr::now)); }(); const auto caption = (options.hideCaption || options.ignoreMessageText) ? TextWithEntities() diff --git a/Telegram/SourceFiles/data/data_peer.h b/Telegram/SourceFiles/data/data_peer.h index 6df672719c..3186b5a5ee 100644 --- a/Telegram/SourceFiles/data/data_peer.h +++ b/Telegram/SourceFiles/data/data_peer.h @@ -634,7 +634,7 @@ private: crl::time _lastFullUpdate = 0; QString _name; - uint32 _nameVersion : 29 = 1; + uint32 _nameVersion : 16 = 1; uint32 _sensitiveContent : 1 = 0; uint32 _wallPaperOverriden : 1 = 0; uint32 _checkedTrustedPayForMessage : 1 = 0; diff --git a/Telegram/SourceFiles/data/data_saved_sublist.h b/Telegram/SourceFiles/data/data_saved_sublist.h index b1d2422587..c62b618644 100644 --- a/Telegram/SourceFiles/data/data_saved_sublist.h +++ b/Telegram/SourceFiles/data/data_saved_sublist.h @@ -194,8 +194,6 @@ private: bool _restorePinnedWhenNonEmpty = false; - mtpRequestId _reloadUnreadCountRequestId = 0; - rpl::lifetime _lifetime; }; diff --git a/Telegram/SourceFiles/data/data_session.cpp b/Telegram/SourceFiles/data/data_session.cpp index 57fe22d4f2..cb95a38810 100644 --- a/Telegram/SourceFiles/data/data_session.cpp +++ b/Telegram/SourceFiles/data/data_session.cpp @@ -30,6 +30,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history.h" #include "history/history_item.h" #include "history/history_item_components.h" +#include "history/history_streamed_drafts.h" #include "history/view/media/history_view_media.h" #include "history/view/history_view_element.h" #include "inline_bots/inline_bot_layout_item.h" @@ -2109,6 +2110,11 @@ void Session::requestItemTextRefresh(not_null item) { view->itemTextUpdated(); }); requestItemResize(item); + if (item->textAppearing()) { + enumerateItemViews(item, [&](not_null view) { + view->skipInactiveTextAppearing(); + }); + } }; if (const auto group = groups().find(item)) { call(group->items.front()); @@ -2117,14 +2123,6 @@ void Session::requestItemTextRefresh(not_null item) { } } -void Session::requestItemTextRefreshStreaming( - not_null item) { - enumerateItemViews(item, [&](not_null view) { - view->itemTextUpdatedStreaming(); - }); - requestItemResize(item); -} - void Session::registerRestricted( not_null item, const QString &reason) { @@ -3179,6 +3177,20 @@ HistoryItem *Session::addNewMessage( return nullptr; } + if (data.type() == mtpc_message) { + if (const auto h = historyLoaded(peerId)) { + if (const auto streamed = h->streamedDraftsIfExists()) { + if (const auto adopted = streamed->adoptIncoming( + data.c_message())) { + if (type == NewMessageType::Unread) { + CheckForSwitchInlineButton(adopted); + } + return adopted; + } + } + } + } + const auto result = history(peerId)->addNewMessage( id, data, @@ -4206,6 +4218,21 @@ void Session::webpageApplyFields( auto iv = (data.vcached_page() && !IgnoreIv(type)) ? std::make_unique(data, *data.vcached_page()) : nullptr; + const auto resolvedPhoto = story + ? story->photo() + : photo + ? processPhoto(*photo).get() + : nullptr; + const auto resolvedDocument = story + ? story->document() + : document + ? processDocument(*document).get() + : lookupThemeDocument(); + const auto photoIsVideoCover = data.is_video_cover_photo() + || (resolvedDocument + && resolvedPhoto + && resolvedDocument->isVideoFile() + && !resolvedDocument->hasThumbnail()); webpageApplyFields( page, type, @@ -4215,16 +4242,8 @@ void Session::webpageApplyFields( qs(data.vtitle().value_or_empty()), (story ? story->caption() : description), storyId, - (story - ? story->photo() - : photo - ? processPhoto(*photo).get() - : nullptr), - (story - ? story->document() - : document - ? processDocument(*document).get() - : lookupThemeDocument()), + resolvedPhoto, + resolvedDocument, WebPageCollage(this, data), std::move(iv), lookupStickerSet(), @@ -4233,7 +4252,7 @@ void Session::webpageApplyFields( data.vduration().value_or_empty(), qs(data.vauthor().value_or_empty()), data.is_has_large_media(), - data.is_video_cover_photo(), + photoIsVideoCover, pendingTill); } diff --git a/Telegram/SourceFiles/data/data_session.h b/Telegram/SourceFiles/data/data_session.h index 1e54a52ab4..e66c1b81d3 100644 --- a/Telegram/SourceFiles/data/data_session.h +++ b/Telegram/SourceFiles/data/data_session.h @@ -406,7 +406,6 @@ public: void requestItemViewRefresh(not_null item); [[nodiscard]] rpl::producer> itemViewRefreshRequest() const; void requestItemTextRefresh(not_null item); - void requestItemTextRefreshStreaming(not_null item); void requestUnreadReactionsAnimation(not_null item); void notifyHistoryUnloaded(not_null history); [[nodiscard]] rpl::producer> historyUnloaded() const; diff --git a/Telegram/SourceFiles/data/data_todo_list.cpp b/Telegram/SourceFiles/data/data_todo_list.cpp index ca98523b61..1496490e18 100644 --- a/Telegram/SourceFiles/data/data_todo_list.cpp +++ b/Telegram/SourceFiles/data/data_todo_list.cpp @@ -18,8 +18,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace { -constexpr auto kShortPollTimeout = 30 * crl::time(1000); - const TodoListItem *ItemById(const std::vector &list, int id) { const auto i = ranges::find(list, id, &TodoListItem::id); return (i != end(list)) ? &*i : nullptr; diff --git a/Telegram/SourceFiles/data/data_types.h b/Telegram/SourceFiles/data/data_types.h index fe3a3c9f40..dfc49671a4 100644 --- a/Telegram/SourceFiles/data/data_types.h +++ b/Telegram/SourceFiles/data/data_types.h @@ -366,6 +366,7 @@ enum class MessageFlag : uint64 { HasUnreadPollVote = (1ULL << 59), TextAppearing = (1ULL << 60), + TextAppearingStarted = (1ULL << 61), AyuNoForwards = (1ULL << 63), }; diff --git a/Telegram/SourceFiles/data/stickers/data_stickers.cpp b/Telegram/SourceFiles/data/stickers/data_stickers.cpp index 5d6c142301..47caa7403c 100644 --- a/Telegram/SourceFiles/data/stickers/data_stickers.cpp +++ b/Telegram/SourceFiles/data/stickers/data_stickers.cpp @@ -1609,7 +1609,8 @@ not_null Stickers::feedSet(const MTPStickerSet &info) { & (SetFlag::Featured | SetFlag::Unread | SetFlag::NotLoaded - | SetFlag::Special); + | SetFlag::Special + | SetFlag::Installed); set->flags = flags | clientFlags; const auto installDate = data.vinstalled_date(); set->installDate = installDate diff --git a/Telegram/SourceFiles/dialogs/dialogs.style b/Telegram/SourceFiles/dialogs/dialogs.style index 6476094b16..04fd48d772 100644 --- a/Telegram/SourceFiles/dialogs/dialogs.style +++ b/Telegram/SourceFiles/dialogs/dialogs.style @@ -540,10 +540,6 @@ dialogsScamFont: font(9px semibold); dialogsScamSkip: 4px; dialogsScamRadius: 2px; -dialogsUnreadMediaSize: 5px; -dialogsUnreadMediaSkip: 4px; -dialogsUnreadMediaTop: 7px; - dialogsMiniPreviewTop: 1px; dialogsMiniPreview: 16px; dialogsMiniPreviewRadius: 2px; @@ -551,6 +547,11 @@ dialogsMiniPreviewSkip: 2px; dialogsMiniPreviewRight: 3px; dialogsMiniPlay: icon{{ "dialogs/dialogs_mini_play", videoPlayIconFg }}; +dialogsUnreadMediaDotEmoji: IconEmoji { + icon: icon{{ "dialogs/dialogs_unread_media_dot", dialogsTextFgService }}; + padding: margins(1px, 7px, 2px, 0px); +} + dialogsMiniForward: DialogsMiniIcon { icon: ThreeStateIcon { icon: icon {{ "mini_forward", dialogsTextFg, point(0px, 1px) }}; diff --git a/Telegram/SourceFiles/dialogs/dialogs_entry.cpp b/Telegram/SourceFiles/dialogs/dialogs_entry.cpp index 9b77e31814..5a6ab2d037 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_entry.cpp +++ b/Telegram/SourceFiles/dialogs/dialogs_entry.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "dialogs/dialogs_key.h" #include "dialogs/dialogs_indexed_list.h" +#include "base/unixtime.h" #include "data/data_changes.h" #include "data/data_session.h" #include "data/data_folder.h" @@ -20,6 +21,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "mainwidget.h" #include "main/main_session.h" #include "main/main_session_settings.h" +#include "ui/text/format_values.h" #include "ui/text/text_options.h" #include "ui/ui_utility.h" #include "history/history.h" @@ -309,6 +311,33 @@ const Ui::Text::String &Entry::chatListNameText() const { return _chatListNameText; } +DateText ResolveDateText( + DateTextCache &cache, + TimeId date, + crl::time now) { + static crl::time LastNow = 0; + static int LastTodaySerial = 0; + if (!now || LastNow != now) { + LastNow = now; + LastTodaySerial = int(QDate::currentDate().toJulianDay()); + } + if (cache.messageTimeId != date + || cache.todaySerial != LastTodaySerial) { + const auto qdt = base::unixtime::parse(date); + cache.text = Ui::FormatDialogsDate(qdt); + cache.width = st::dialogsDateFont->width(cache.text); + cache.messageTimeId = date; + cache.todaySerial = LastTodaySerial; + } + return { cache.text, cache.width }; +} + +DateText Entry::chatListTimestampText( + TimeId date, + crl::time now) const { + return ResolveDateText(_chatListDateCache, date, now); +} + void Entry::setChatListExistence(bool exists) { if (exists && _sortKeyInChatList) { owner().refreshChatListEntry(this); diff --git a/Telegram/SourceFiles/dialogs/dialogs_entry.h b/Telegram/SourceFiles/dialogs/dialogs_entry.h index 8838bd05fb..2026f8f879 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_entry.h +++ b/Telegram/SourceFiles/dialogs/dialogs_entry.h @@ -52,6 +52,23 @@ class MainList; CountInBadge count = CountInBadge::Default, IncludeInBadge include = IncludeInBadge::Default); +struct DateTextCache { + QString text; + TimeId messageTimeId = 0; + int todaySerial = 0; + int width = 0; +}; + +struct DateText { + const QString &text; + int width = 0; +}; + +[[nodiscard]] DateText ResolveDateText( + DateTextCache &cache, + TimeId date, + crl::time now); + class Entry : public base::has_weak_ptr { public: enum class Type : uchar { @@ -154,6 +171,9 @@ public: } [[nodiscard]] const Ui::Text::String &chatListNameText() const; + [[nodiscard]] DateText chatListTimestampText( + TimeId date, + crl::time now) const; [[nodiscard]] Ui::PeerBadge &chatListPeerBadge() const { return _chatListPeerBadge; } @@ -194,6 +214,7 @@ private: mutable Ui::PeerBadge _chatListPeerBadge; mutable Ui::Text::String _chatListNameText; mutable int _chatListNameVersion = 0; + mutable DateTextCache _chatListDateCache; TimeId _timeId = 0; Flags _flags; diff --git a/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp b/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp index f23d8f8fd9..9e940482f6 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp +++ b/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp @@ -2947,7 +2947,11 @@ void InnerWidget::dialogRowReplaced( _selected = newRow; } if (_pressed == oldRow) { - setPressed(newRow, _pressedTopicJump, _pressedRightButton); + if (newRow) { + setPressed(newRow, _pressedTopicJump, _pressedRightButton); + } else { + clearPressed(); + } } if (_dragging == oldRow) { if (newRow) { diff --git a/Telegram/SourceFiles/dialogs/dialogs_row.cpp b/Telegram/SourceFiles/dialogs/dialogs_row.cpp index c902c78725..3fd064bb34 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_row.cpp +++ b/Telegram/SourceFiles/dialogs/dialogs_row.cpp @@ -785,4 +785,10 @@ const Ui::Text::String &FakeRow::name() const { return _name; } +DateText FakeRow::dateText( + TimeId date, + crl::time now) const { + return ResolveDateText(_dateCache, date, now); +} + } // namespace Dialogs diff --git a/Telegram/SourceFiles/dialogs/dialogs_row.h b/Telegram/SourceFiles/dialogs/dialogs_row.h index 7bf981d17d..7e2f9a6708 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_row.h +++ b/Telegram/SourceFiles/dialogs/dialogs_row.h @@ -11,6 +11,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/text/text.h" #include "ui/unread_badge.h" #include "ui/userpic_view.h" +#include "dialogs/dialogs_entry.h" #include "dialogs/dialogs_key.h" #include "dialogs/ui/dialogs_message_view.h" @@ -239,6 +240,9 @@ public: return _badge; } [[nodiscard]] const Ui::Text::String &name() const; + [[nodiscard]] DateText dateText( + TimeId date, + crl::time now) const; void invalidateTopic(); @@ -252,6 +256,7 @@ private: mutable Ui::MessageView _itemView; mutable Ui::PeerBadge _badge; mutable Ui::Text::String _name; + mutable DateTextCache _dateCache; }; diff --git a/Telegram/SourceFiles/dialogs/ui/dialogs_layout.cpp b/Telegram/SourceFiles/dialogs/ui/dialogs_layout.cpp index 56ed09c186..28c49226d2 100644 --- a/Telegram/SourceFiles/dialogs/ui/dialogs_layout.cpp +++ b/Telegram/SourceFiles/dialogs/ui/dialogs_layout.cpp @@ -31,7 +31,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_unread_things.h" #include "history/view/history_view_item_preview.h" #include "history/view/history_view_send_action.h" -#include "lang/lang_instance.h" #include "lang/lang_keys.h" #include "lottie/lottie_icon.h" #include "main/main_session.h" @@ -91,8 +90,11 @@ void PaintRowTopRight( QPainter &p, const QString &text, QRect &rectForName, - const PaintContext &context) { - const auto width = st::dialogsDateFont->width(text); + const PaintContext &context, + int precomputedWidth = -1) { + const auto width = (precomputedWidth >= 0) + ? precomputedWidth + : st::dialogsDateFont->width(text); rectForName.setWidth(rectForName.width() - width - st::dialogsDateSkip); p.setFont(st::dialogsDateFont); p.setPen(context.active @@ -384,6 +386,19 @@ enum class Flag { }; inline constexpr bool is_flag_type(Flag) { return true; } +void PaintDialogDate( + QPainter &p, + not_null entry, + const FakeRow *fakeRow, + TimeId date, + QRect &rectForName, + const PaintContext &context) { + const auto resolved = fakeRow + ? fakeRow->dateText(date, context.now) + : entry->chatListTimestampText(date, context.now); + PaintRowTopRight(p, resolved.text, rectForName, context, resolved.width); +} + template void PaintRow( Painter &p, @@ -398,8 +413,9 @@ void PaintRow( const HiddenSenderInfo *hiddenSenderInfo, HistoryItem *item, const Data::Draft *draft, - QDateTime date, + TimeId date, const PaintContext &context, + const FakeRow *fakeRow, BadgesState badgesState, base::flags flags, PaintItemCallback &&paintItemCallback) { @@ -593,8 +609,7 @@ void PaintRow( || (supportMode && entry->session().supportHelper().isOccupiedBySomeone(history))) { if (!promoted) { - const auto dateString = Ui::FormatDialogsDate(date); - PaintRowTopRight(p, dateString, rectForName, context); + PaintDialogDate(p, entry, fakeRow, date, rectForName, context); } auto availableWidth = namewidth; @@ -725,8 +740,7 @@ void PaintRow( } } else if (!item->isEmpty()) { if ((thread || sublist) && !promoted) { - const auto dateString = Ui::FormatDialogsDate(date); - PaintRowTopRight(p, dateString, rectForName, context); + PaintDialogDate(p, entry, fakeRow, date, rectForName, context); } paintItemCallback(nameleft, namewidth); @@ -1073,18 +1087,10 @@ void RowPainter::Paint( } return nullptr; }(); - const auto displayDate = [&] { - if (item) { - if (cloudDraft) { - return (item->date() > cloudDraft->date) - ? ItemDateTime(item) - : base::unixtime::parse(cloudDraft->date); - } - return ItemDateTime(item); - } - return cloudDraft - ? base::unixtime::parse(cloudDraft->date) - : QDateTime(); + const auto displayDate = [&]() -> TimeId { + const auto itemDate = item ? item->date() : TimeId(0); + const auto draftDate = cloudDraft ? cloudDraft->date : TimeId(0); + return std::max(itemDate, draftDate); }(); const auto displayPinnedIcon = badgesState.empty() && entry->isPinnedDialog(context.filter) @@ -1184,6 +1190,7 @@ void RowPainter::Paint( cloudDraft, displayDate, context, + nullptr, badgesState, flags, paintItemCallback); @@ -1293,8 +1300,9 @@ void RowPainter::Paint( hiddenSenderInfo, item, cloudDraft, - ItemDateTime(item), + item->date(), context, + row, badgesState, flags, paintItemCallback); diff --git a/Telegram/SourceFiles/dialogs/ui/dialogs_message_view.cpp b/Telegram/SourceFiles/dialogs/ui/dialogs_message_view.cpp index 94afd3ef97..4d1e1bde99 100644 --- a/Telegram/SourceFiles/dialogs/ui/dialogs_message_view.cpp +++ b/Telegram/SourceFiles/dialogs/ui/dialogs_message_view.cpp @@ -382,9 +382,6 @@ int MessageView::countWidth() const { * (st::dialogsMiniPreview + st::dialogsMiniPreviewSkip)) + st::dialogsMiniPreviewRight; } - if (_unreadMedia) { - result += st::dialogsUnreadMediaSize + st::dialogsUnreadMediaSkip; - } return result + _textCache.maxWidth(); } @@ -508,31 +505,6 @@ void MessageView::paint( rect.setLeft(rect.x() + st::dialogsMiniPreviewRight); } // Style of _textCache. - if (_unreadMedia && rect.width() - >= st::dialogsUnreadMediaSize + st::dialogsUnreadMediaSkip) { - { - PainterHighQualityEnabler hq(p); - p.setPen(Qt::NoPen); - p.setBrush(context.active - ? st::dialogsTextFgServiceActive - : context.selected - ? st::dialogsTextFgServiceOver - : st::dialogsTextFgService); - p.drawEllipse( - rect.x(), - rect.y() + st::dialogsUnreadMediaTop, - st::dialogsUnreadMediaSize, - st::dialogsUnreadMediaSize); - } - p.setPen(context.active - ? st::dialogsTextFgActive - : context.selected - ? st::dialogsTextFgOver - : st::dialogsTextFg); - rect.setLeft(rect.x() - + st::dialogsUnreadMediaSize - + st::dialogsUnreadMediaSkip); - } static const auto ellipsisWidth = st::dialogsTextStyle.font->width( kQEllipsis); if (rect.width() > ellipsisWidth) { diff --git a/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.cpp b/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.cpp index b6d244b8db..d93db6152e 100644 --- a/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.cpp +++ b/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.cpp @@ -596,7 +596,6 @@ private: Fn)> _filterOut; rpl::producer<> _filterOutRefreshes; - History *_activeHistory = nullptr; bool _requested = false; rpl::lifetime _lifetime; diff --git a/Telegram/SourceFiles/editor/color_picker.cpp b/Telegram/SourceFiles/editor/color_picker.cpp index cd19a75a06..c4f74fe1ba 100644 --- a/Telegram/SourceFiles/editor/color_picker.cpp +++ b/Telegram/SourceFiles/editor/color_picker.cpp @@ -351,6 +351,7 @@ ColorPicker::ColorPicker( button->show(); } const auto setToolRequest = [=](Brush::Tool tool) { + _toolClicks.fire({}); setTool(tool); }; if (_toolButtons.size() >= 5) { @@ -566,10 +567,28 @@ void ColorPicker::setTool(Brush::Tool tool) { } void ColorPicker::storeCurrentBrush() { + if (_toolSelectionSuppressed) { + return; + } NormalizeBrushColor(_brush); _toolBrushes[ToolIndex(_brush.tool)] = _brush; } +void ColorPicker::setColor(const QColor &color) { + _brush.color = color; + updateColorButtonColor(color, true); + if (_paletteVisible) { + rebuildPalette(); + } else { + _colorButton->update(); + } +} + +void ColorPicker::setToolSelectionVisible(bool visible) { + _toolSelectionSuppressed = !visible; + _toolSelection->setVisible(visible); +} + void ColorPicker::updateColorButtonColor(const QColor &color, bool animated) { const auto hasValid = _colorButtonFrom.isValid() && _colorButtonTo.isValid(); const auto from = hasValid ? colorButtonColor() : color; @@ -632,11 +651,14 @@ void ColorPicker::setVisible(bool visible) { _paletteWrap->setVisible(visible && _paletteVisible); _sizeControlHoverArea->setVisible(visible); _sizeControl->setVisible(visible); - _toolSelection->setVisible(visible && !_paletteVisible); + const auto showTools = visible + && !_paletteVisible + && !_toolSelectionSuppressed; + _toolSelection->setVisible(showTools); for (const auto &button : _toolButtons) { button->setVisible(visible && !_paletteVisible); } - if (visible && !_paletteVisible) { + if (showTools) { updateToolSelection(false); } } @@ -645,6 +667,10 @@ rpl::producer ColorPicker::saveBrushRequests() const { return _saveBrushRequests.events_starting_with_copy(_brush); } +rpl::producer<> ColorPicker::toolClicks() const { + return _toolClicks.events(); +} + bool ColorPicker::preventHandleKeyPress() const { return _sizeControl->isVisible() && (_sizeControlAnimation.animating() || _sizeDown.pressed); @@ -785,13 +811,14 @@ void ColorPicker::setPaletteVisible(bool visible) { _paletteVisible = visible; _paletteWrap->setVisible(visible); _colorButton->setVisible(!visible); - _toolSelection->setVisible(!visible); + const auto showTools = !visible && !_toolSelectionSuppressed; + _toolSelection->setVisible(showTools); for (const auto &button : _toolButtons) { button->setVisible(!visible); } if (visible) { rebuildPalette(); - } else { + } else if (showTools) { updateToolSelection(false); } } diff --git a/Telegram/SourceFiles/editor/color_picker.h b/Telegram/SourceFiles/editor/color_picker.h index 63b0211b2f..3d64431ce6 100644 --- a/Telegram/SourceFiles/editor/color_picker.h +++ b/Telegram/SourceFiles/editor/color_picker.h @@ -30,9 +30,12 @@ public: void moveLine(const QPoint &position); void setCanvasRect(const QRect &rect); void setVisible(bool visible); + void setColor(const QColor &color); + void setToolSelectionVisible(bool visible); bool preventHandleKeyPress() const; rpl::producer saveBrushRequests() const; + rpl::producer<> toolClicks() const; private: void paintSizeControl(QPainter &p); @@ -75,6 +78,7 @@ private: int y = 0; bool pressed = false; } _sizeDown; + bool _toolSelectionSuppressed = false; bool _sizeHoverAreaHovered = false; bool _sizeControlHovered = false; bool _sizeControlExpanded = false; @@ -94,6 +98,7 @@ private: Ui::Animations::Simple _toolSelectionAnimation; rpl::event_stream _saveBrushRequests; + rpl::event_stream<> _toolClicks; std::vector> _paletteButtons; base::unique_qptr _palettePlus; diff --git a/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.cpp b/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.cpp index 831ccfaa8c..2958e1b4e2 100644 --- a/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.cpp +++ b/Telegram/SourceFiles/editor/controllers/stickers_panel_controller.cpp @@ -27,7 +27,7 @@ StickersPanelController::StickersPanelController( nullptr, ChatHelpers::TabbedSelectorDescriptor{ .show = show, - .st = st::storiesComposeControls.tabbed, + .st = st::mediaviewEmojiPan, .level = Window::GifPauseReason::Layer, .mode = ChatHelpers::TabbedSelector::Mode::MediaEditor, .features = { diff --git a/Telegram/SourceFiles/editor/editor.style b/Telegram/SourceFiles/editor/editor.style index 8fed9f4fab..1d1aede531 100644 --- a/Telegram/SourceFiles/editor/editor.style +++ b/Telegram/SourceFiles/editor/editor.style @@ -10,6 +10,7 @@ using "ui/basic.style"; using "window/window.style"; using "ui/widgets/widgets.style"; using "ui/chat/chat.style"; +using "media/view/media_view.style"; // photoEditorControlsBottomSkip * 2 + photoEditorControlsCenterSkip + photoEditorButtonBarHeight * 2 photoEditorControlsHeight: 146px; @@ -95,9 +96,27 @@ photoEditorCropRatioButton: IconButton(photoEditorRotateButton) { } photoEditorCropRatioIconActive: icon {{ "photo_editor/ratio-23x23", photoEditorButtonIconFgActive, point(0px, 1px) }}; -photoEditorCropRatioMenu: PopupMenu(popupMenuWithIcons) { - menu: Menu(menuWithIcons) { +photoEditorCornersButton: IconButton(photoEditorRotateButton) { + icon: icon {{ "photo_editor/corners-23x23", photoEditorButtonIconFg, point(0px, 1px) }}; + iconOver: icon {{ "photo_editor/corners-23x23", photoEditorButtonIconFgOver, point(0px, 1px) }}; +} +photoEditorCornersIconActive: icon {{ "photo_editor/corners-23x23", photoEditorButtonIconFgActive, point(0px, 1px) }}; + +photoEditorCornersMenuAboutLabel: FlatLabel(defaultFlatLabel) { + style: TextStyle(defaultTextStyle) { + font: font(11px); + } + minWidth: 136px; + textFg: groupCallMemberNotJoinedStatus; +} +photoEditorCornersMenuAboutPosition: point(12px, 4px); + +photoEditorMenuSubmenuArrow: icon {{ "menu/submenu_arrow", groupCallMemberNotJoinedStatus }}; + +photoEditorCropRatioMenu: PopupMenu(mediaviewPopupMenu) { + menu: Menu(mediaviewMenu) { widthMin: 108px; + arrow: photoEditorMenuSubmenuArrow; } } diff --git a/Telegram/SourceFiles/editor/editor_crop.cpp b/Telegram/SourceFiles/editor/editor_crop.cpp index 0fd553768f..c68357c41b 100644 --- a/Telegram/SourceFiles/editor/editor_crop.cpp +++ b/Telegram/SourceFiles/editor/editor_crop.cpp @@ -82,7 +82,8 @@ Crop::Crop( : QRectF(QPoint(), _imageSize)) , _angle(modifications.angle) , _flipped(modifications.flipped) -, _keepAspectRatio(_data.keepAspectRatio) { +, _keepAspectRatio(_data.keepAspectRatio) +, _cornersLevel(modifications.cornersLevel) { setMouseTracking(true); @@ -158,9 +159,15 @@ QPainterPath Crop::cropPath() const { if (_data.cropType == EditorData::CropType::Ellipse) { result.addEllipse(_cropPaint); } else if (_data.cropType == EditorData::CropType::RoundedRect) { - const auto radius = std::min(_cropPaint.width(), _cropPaint.height()) - * Ui::ForumUserpicRadiusMultiplier(); - result.addRoundedRect(_cropPaint, radius, radius); + const auto multiplier = RoundedCornersMultiplier(_cornersLevel); + if (multiplier <= 0.) { + result.addRect(_cropPaint); + } else { + const auto radius = std::min( + _cropPaint.width(), + _cropPaint.height()) * multiplier; + result.addRoundedRect(_cropPaint, radius, radius); + } } else { result.addRect(_cropPaint); } @@ -177,6 +184,10 @@ void Crop::paintFrame(QPainter &p) { p.save(); p.setRenderHint(QPainter::Antialiasing, true); p.fillPath(frameShape, st::photoCropPointFg); + if (_data.fixedCrop) { + p.restore(); + return; + } { const auto cornerLength = std::min( float64(st::photoEditorCropPointSize * 2), @@ -286,6 +297,10 @@ void Crop::convertCropPaintToOriginal() { } void Crop::updateEdges() { + if (_data.fixedCrop) { + _edges.clear(); + return; + } const auto &s = _pointSize; const auto &m = _edgePointMargins; const auto &r = _cropPaint; @@ -338,6 +353,9 @@ Qt::Edges Crop::mouseState(const QPoint &p) { } void Crop::mousePressEvent(QMouseEvent *e) { + if (_data.fixedCrop) { + return; + } computeDownState(e->pos()); if (_down.edge) { setGridVisible(true, false); @@ -345,6 +363,9 @@ void Crop::mousePressEvent(QMouseEvent *e) { } void Crop::mouseReleaseEvent(QMouseEvent *e) { + if (_data.fixedCrop) { + return; + } const auto hadEdge = bool(_down.edge); if (hadEdge) { setGridVisible(false, true); @@ -474,6 +495,9 @@ void Crop::performMove(const QPoint &pos) { } void Crop::mouseMoveEvent(QMouseEvent *e) { + if (_data.fixedCrop) { + return; + } const auto pos = e->pos(); const auto pressedEdge = _down.edge; @@ -548,6 +572,17 @@ void Crop::setAspectRatio(float64 ratio) { update(); } +void Crop::setCornersLevel(RoundedCornersLevel level) { + if (_cornersLevel == level) { + return; + } + _cornersLevel = level; + _painterPath.clear(); + _painterPath.addRect(_innerRect); + _painterPath.addPath(cropPath()); + update(); +} + QRect Crop::saveCropRect() { const auto savedCrop = _cropOriginal.toRect(); return (!savedCrop.topLeft().isNull() || (savedCrop.size() != _imageSize)) diff --git a/Telegram/SourceFiles/editor/editor_crop.h b/Telegram/SourceFiles/editor/editor_crop.h index b9d7eec655..f0eab5d050 100644 --- a/Telegram/SourceFiles/editor/editor_crop.h +++ b/Telegram/SourceFiles/editor/editor_crop.h @@ -32,6 +32,7 @@ public: [[nodiscard]] QRect saveCropRect(); [[nodiscard]] style::margins cropMargins() const; void setAspectRatio(float64 ratio); + void setCornersLevel(RoundedCornersLevel level); protected: void mousePressEvent(QMouseEvent *e) override; @@ -99,6 +100,8 @@ private: bool _keepAspectRatio = false; + RoundedCornersLevel _cornersLevel = RoundedCornersLevel::Large; + }; } // namespace Editor diff --git a/Telegram/SourceFiles/editor/editor_paint.cpp b/Telegram/SourceFiles/editor/editor_paint.cpp index a90cc8af63..15577df5c8 100644 --- a/Telegram/SourceFiles/editor/editor_paint.cpp +++ b/Telegram/SourceFiles/editor/editor_paint.cpp @@ -12,6 +12,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene_item_canvas.h" #include "editor/scene/scene_item_image.h" #include "editor/scene/scene_item_sticker.h" +#include "editor/scene/scene_item_text.h" #include "editor/scene/scene.h" #include "lang/lang_keys.h" #include "lottie/lottie_single_player.h" @@ -35,6 +36,9 @@ constexpr auto kMinCanvasZoom = 1.; constexpr auto kMaxCanvasZoom = 8.; constexpr auto kCanvasZoomStep = 1.15; constexpr auto kZoomEpsilon = 0.0001; +constexpr auto kMinItemZoom = 0.1; +constexpr auto kMaxItemZoom = 10.; +constexpr auto kCanvasZoomStepFine = 1.015; std::shared_ptr EnsureScene( PhotoModifications &mods, @@ -54,17 +58,30 @@ Paint::Paint( PhotoModifications &modifications, const QSize &imageSize, std::shared_ptr controllers, - Fn blurSource) + Fn blurSource, + bool fixedCrop) : RpWidget(parent) , _controllers(controllers) , _scene(EnsureScene(modifications, imageSize)) , _view(base::make_unique_q(_scene.get(), this)) , _viewport(_view->viewport()) -, _imageSize(imageSize) { +, _imageSize(imageSize) +, _fixedCrop(fixedCrop) { Expects(modifications.paint != nullptr); _scene->setBlurSource(std::move(blurSource)); + { + constexpr auto kDefaultFontSizeDivisor = 15.; + const auto shortSide = std::min( + imageSize.width(), + imageSize.height()); + _scene->setTextDefaults( + QColor(255, 255, 255), + shortSide / kDefaultFontSizeDivisor, + int(TextStyle::Plain)); + } + keepResult(); _view->show(); @@ -77,9 +94,17 @@ Paint::Paint( _viewport->setAttribute(Qt::WA_TranslucentBackground, true); _viewport->installEventFilter(this); + _scene->textEditStates( + ) | rpl::on_next([=](bool editing) { + _textEditing = editing; + }, lifetime()); + // Undo / Redo. controllers->undoController->performRequestChanges( ) | rpl::on_next([=](const Undo &command) { + if (_textEditing.current()) { + return; + } if (command == Undo::Undo) { _scene->performUndo(); } else { @@ -91,16 +116,22 @@ Paint::Paint( }, lifetime()); controllers->undoController->setCanPerformChanges(rpl::merge( - _hasUndo.value() | rpl::map([](bool enable) { + rpl::combine( + _hasUndo.value(), + _textEditing.value() + ) | rpl::map([](bool enable, bool editing) { return UndoController::EnableRequest{ .command = Undo::Undo, - .enable = enable, + .enable = enable && !editing, }; }), - _hasRedo.value() | rpl::map([](bool enable) { + rpl::combine( + _hasRedo.value(), + _textEditing.value() + ) | rpl::map([](bool enable, bool editing) { return UndoController::EnableRequest{ .command = Undo::Redo, - .enable = enable, + .enable = enable && !editing, }; }))); @@ -139,6 +170,51 @@ Paint::Paint( } +bool Paint::zoomSceneItems(float64 wheelDelta, bool fine) { + if (!wheelDelta) { + return false; + } + const auto step = wheelDelta + / float64(QWheelEvent::DefaultDeltasPerStep); + const auto base = fine ? kCanvasZoomStepFine : kCanvasZoomStep; + const auto factor = std::pow(base, step); + const auto center = rect::center(_scene->sceneRect()); + auto applied = false; + for (const auto &item : _scene->items()) { + const auto raw = item.get(); + const auto oldScale = raw->scale(); + const auto newScale = std::clamp( + oldScale * factor, + kMinItemZoom, + kMaxItemZoom); + if (std::abs(newScale - oldScale) < kZoomEpsilon) { + continue; + } + const auto ratio = newScale / oldScale; + raw->setScale(newScale); + const auto pos = raw->pos(); + raw->setPos(center + (pos - center) * ratio); + applied = true; + } + return applied; +} + +void Paint::panSceneItems(QPointF sceneDelta) { + if (sceneDelta.isNull()) { + return; + } + for (const auto &item : _scene->items()) { + item->setPos(item->pos() + sceneDelta); + } +} + +QPointF Paint::mapWidgetDeltaToScene(QPoint delta) const { + if (!_view) { + return QPointF(delta); + } + return _view->mapToScene(delta) - _view->mapToScene(QPoint()); +} + Paint::~Paint() { if (_viewport) { _viewport->removeEventFilter(this); @@ -234,6 +310,38 @@ void Paint::applyBrush(const Brush &brush) { brush.tool); } +void Paint::createTextItem() { + _scene->createTextAtCenter(); +} + +void Paint::clearSelection() { + _scene->clearSelection(); +} + +void Paint::setTextColor(const QColor &color) { + _scene->setTextColor(color); +} + +void Paint::setSelectedTextColor(const QColor &color) { + _scene->setSelectedTextColor(color); +} + +rpl::producer Paint::textColorRequests() const { + return _scene->textColorRequests(); +} + +rpl::producer Paint::textItemSelections() const { + return _scene->textItemSelections(); +} + +rpl::producer<> Paint::textItemDeselections() const { + return _scene->textItemDeselections(); +} + +rpl::producer Paint::textEditStates() const { + return _scene->textEditStates(); +} + void Paint::handleMimeData(const QMimeData *data) { const auto add = [&](QImage image) { if (image.isNull()) { @@ -330,11 +438,18 @@ bool Paint::eventFilter(QObject *obj, QEvent *e) { } if (e->type() == QEvent::Wheel) { const auto wheel = static_cast(e); - const auto delta = wheel->angleDelta().y(); + const auto raw = wheel->angleDelta(); + const auto delta = raw.y() ? raw.y() : raw.x(); if (!delta) { return true; } + if (_fixedCrop) { + zoomSceneItems( + delta, + wheel->modifiers().testFlag(Qt::ShiftModifier)); + return true; + } const auto step = delta / float64(QWheelEvent::DefaultDeltasPerStep); const auto factor = std::pow(kCanvasZoomStep, step); const auto newZoom = std::clamp( @@ -363,7 +478,8 @@ bool Paint::eventFilter(QObject *obj, QEvent *e) { const auto mouse = static_cast(e); if (mouse->button() == Qt::MiddleButton) { _pan = { - .active = (_transform.userZoom > kMinCanvasZoom), + .active = (_fixedCrop + || _transform.userZoom > kMinCanvasZoom), .point = mouse->pos(), }; if (_pan.active) { @@ -378,7 +494,9 @@ bool Paint::eventFilter(QObject *obj, QEvent *e) { const auto delta = point - _pan.point; _pan.point = point; - if (_transform.userZoom > kMinCanvasZoom) { + if (_fixedCrop) { + panSceneItems(mapWidgetDeltaToScene(delta)); + } else if (_transform.userZoom > kMinCanvasZoom) { view->horizontalScrollBar()->setValue( view->horizontalScrollBar()->value() - delta.x()); view->verticalScrollBar()->setValue( diff --git a/Telegram/SourceFiles/editor/editor_paint.h b/Telegram/SourceFiles/editor/editor_paint.h index b1bfb68c5b..8d100ee4ad 100644 --- a/Telegram/SourceFiles/editor/editor_paint.h +++ b/Telegram/SourceFiles/editor/editor_paint.h @@ -29,7 +29,8 @@ public: PhotoModifications &modifications, const QSize &imageSize, std::shared_ptr controllers, - Fn blurSource); + Fn blurSource, + bool fixedCrop = false); ~Paint() override; [[nodiscard]] std::shared_ptr saveScene() const; @@ -41,10 +42,24 @@ public: void keepResult(); void updateUndoState(); + void createTextItem(); + void clearSelection(); + void setTextColor(const QColor &color); + void setSelectedTextColor(const QColor &color); + + [[nodiscard]] rpl::producer textColorRequests() const; + [[nodiscard]] rpl::producer textItemSelections() const; + [[nodiscard]] rpl::producer<> textItemDeselections() const; + [[nodiscard]] rpl::producer textEditStates() const; + void handleMimeData(const QMimeData *data); void paintImage(QPainter &p, const QPixmap &image) const; void resetView(); + bool zoomSceneItems(float64 wheelDelta, bool fine = false); + void panSceneItems(QPointF sceneDelta); + [[nodiscard]] QPointF mapWidgetDeltaToScene(QPoint delta) const; + private: bool eventFilter(QObject *obj, QEvent *e) override; void updateViewGeometry(); @@ -64,6 +79,7 @@ private: const base::unique_qptr _view; QPointer _viewport; const QSize _imageSize; + const bool _fixedCrop = false; QRect _imageGeometry; QRect _outerGeometry; @@ -84,6 +100,7 @@ private: rpl::variable _hasUndo = true; rpl::variable _hasRedo = true; + rpl::variable _textEditing = false; }; diff --git a/Telegram/SourceFiles/editor/photo_editor.cpp b/Telegram/SourceFiles/editor/photo_editor.cpp index 544ccfcc8a..50476bcf07 100644 --- a/Telegram/SourceFiles/editor/photo_editor.cpp +++ b/Telegram/SourceFiles/editor/photo_editor.cpp @@ -151,7 +151,7 @@ struct BrushState { const auto tool = ToolFromSerialized(entryTool); const auto index = ToolIndex(tool); if (version == kBrushesVersion && size > 0) { - result.brushes[index].sizeRatio = size / float(kPrecision); + result.brushes[index].sizeRatio = size / float64(kPrecision); } if (color.isValid()) { result.brushes[index].color = color; @@ -237,6 +237,7 @@ PhotoEditor::PhotoEditor( std::move(show), _brushes, _brushTool)) { + _modifications.cropType = data.cropType; sizeValue( ) | rpl::on_next([=](const QSize &size) { @@ -294,6 +295,12 @@ PhotoEditor::PhotoEditor( _content->applyAspectRatio(ratio); }, lifetime()); + _controls->cornersLevelChanges( + ) | rpl::on_next([=](RoundedCornersLevel level) { + _modifications.cornersLevel = level; + _content->applyModifications(_modifications); + }, lifetime()); + _controls->paintModeRequests( ) | rpl::on_next([=] { _mode = PhotoEditorMode{ @@ -302,6 +309,11 @@ PhotoEditor::PhotoEditor( }; }, lifetime()); + _controls->textRequests( + ) | rpl::on_next([=] { + _content->createTextItem(); + }, lifetime()); + _controls->doneRequests( ) | rpl::on_next([=] { const auto mode = _mode.current().mode; @@ -336,18 +348,64 @@ PhotoEditor::PhotoEditor( } }, lifetime()); + _colorPicker->toolClicks( + ) | rpl::on_next([=] { + _content->clearSelection(); + }, lifetime()); + _colorPicker->saveBrushRequests( ) | rpl::on_next([=](const Brush &brush) { - _content->applyBrush(brush); + if (_textItemSelected || _textEditing) { + _content->setSelectedTextColor(brush.color); + _content->setTextColor(brush.color); + } else { + _content->applyBrush(brush); + _content->setTextColor(brush.color); - _brushTool = brush.tool; - _brushes[ToolIndex(brush.tool)] = brush; - const auto serialized = Serialize(_brushes, _brushTool); - if (Core::App().settings().photoEditorBrush() != serialized) { - Core::App().settings().setPhotoEditorBrush(serialized); - Core::App().saveSettingsDelayed(); + _brushTool = brush.tool; + _brushes[ToolIndex(brush.tool)] = brush; + const auto serialized = Serialize(_brushes, _brushTool); + if (Core::App().settings().photoEditorBrush() != serialized) { + Core::App().settings().setPhotoEditorBrush(serialized); + Core::App().saveSettingsDelayed(); + } } }, lifetime()); + + _content->textEditStates( + ) | rpl::on_next([=](bool editing) { + _textEditing = editing; + if (_textEditing) { + _colorPicker->setToolSelectionVisible(false); + } else if (!_textItemSelected) { + const auto &brush = _brushes[ToolIndex(_brushTool)]; + _colorPicker->setColor(brush.color); + _colorPicker->setToolSelectionVisible(true); + } + }, lifetime()); + + _content->textColorRequests( + ) | rpl::on_next([=](const QColor &color) { + _colorPicker->setColor(color); + }, lifetime()); + + _content->textItemSelections( + ) | rpl::on_next([=](const QColor &color) { + _textItemSelected = true; + _colorPicker->setToolSelectionVisible(false); + _colorPicker->setColor(color); + }, lifetime()); + + _content->textItemDeselections( + ) | rpl::on_next([=] { + _textItemSelected = false; + if (_textEditing) { + return; + } + const auto &brush = _brushes[ToolIndex(_brushTool)]; + _colorPicker->setColor(brush.color); + _colorPicker->setToolSelectionVisible(true); + }, lifetime()); } void PhotoEditor::keyPressEvent(QKeyEvent *e) { diff --git a/Telegram/SourceFiles/editor/photo_editor.h b/Telegram/SourceFiles/editor/photo_editor.h index 00351e5b04..2c23a82cf3 100644 --- a/Telegram/SourceFiles/editor/photo_editor.h +++ b/Telegram/SourceFiles/editor/photo_editor.h @@ -72,6 +72,8 @@ private: .mode = PhotoEditorMode::Mode::Transform, .action = PhotoEditorMode::Action::None, }; + bool _textItemSelected = false; + bool _textEditing = false; rpl::event_stream _done; rpl::event_stream<> _cancel; diff --git a/Telegram/SourceFiles/editor/photo_editor_common.cpp b/Telegram/SourceFiles/editor/photo_editor_common.cpp index 4fd72ac829..dc5b700913 100644 --- a/Telegram/SourceFiles/editor/photo_editor_common.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_common.cpp @@ -9,8 +9,59 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene.h" #include "ui/painter.h" +#include "ui/userpic_view.h" namespace Editor { +namespace { + +void ApplyShapeMask( + QImage &image, + EditorData::CropType type, + RoundedCornersLevel cornersLevel) { + if (type == EditorData::CropType::Rect) { + return; + } + const auto multiplier = (type == EditorData::CropType::RoundedRect) + ? RoundedCornersMultiplier(cornersLevel) + : Ui::ForumUserpicRadiusMultiplier(); + if (type == EditorData::CropType::RoundedRect && multiplier <= 0.) { + return; + } + if (image.format() != QImage::Format_ARGB32_Premultiplied) { + image = image.convertToFormat(QImage::Format_ARGB32_Premultiplied); + } + auto mask = QImage(image.size(), QImage::Format_ARGB32_Premultiplied); + mask.fill(Qt::transparent); + { + auto p = QPainter(&mask); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + const auto rect = QRectF(QPointF(), QSizeF(image.size())); + if (type == EditorData::CropType::Ellipse) { + p.drawEllipse(rect); + } else { + const auto radius = std::min(rect.width(), rect.height()) + * multiplier; + p.drawRoundedRect(rect, radius, radius); + } + } + auto p = QPainter(&image); + p.setCompositionMode(QPainter::CompositionMode_DestinationIn); + p.drawImage(0, 0, mask); +} + +} // namespace + +float64 RoundedCornersMultiplier(RoundedCornersLevel level) { + switch (level) { + case RoundedCornersLevel::Large: return Ui::ForumUserpicRadiusMultiplier(); + case RoundedCornersLevel::Medium: return 0.2; + case RoundedCornersLevel::Small: return 0.12; + case RoundedCornersLevel::None: return 0.; + } + Unexpected("Unknown RoundedCornersLevel in RoundedCornersMultiplier."); +} QImage ImageModified(QImage image, const PhotoModifications &mods) { Expects(!image.isNull()); @@ -32,6 +83,7 @@ QImage ImageModified(QImage image, const PhotoModifications &mods) { auto cropped = mods.crop.isValid() ? image.copy(mods.crop) : image; + ApplyShapeMask(cropped, mods.cropType, mods.cornersLevel); QTransform transform; if (mods.flipped) { transform.scale(-1, 1); @@ -43,7 +95,11 @@ QImage ImageModified(QImage image, const PhotoModifications &mods) { } bool PhotoModifications::empty() const { - return !angle && !flipped && !crop.isValid() && !paint; + return !angle + && !flipped + && !crop.isValid() + && cropType == EditorData::CropType::Rect + && !paint; } PhotoModifications::operator bool() const { diff --git a/Telegram/SourceFiles/editor/photo_editor_common.h b/Telegram/SourceFiles/editor/photo_editor_common.h index b52b2110a7..cc98546d99 100644 --- a/Telegram/SourceFiles/editor/photo_editor_common.h +++ b/Telegram/SourceFiles/editor/photo_editor_common.h @@ -11,18 +11,15 @@ namespace Editor { class Scene; -struct PhotoModifications { - int angle = 0; - bool flipped = false; - QRect crop; - std::shared_ptr paint = nullptr; - - [[nodiscard]] bool empty() const; - [[nodiscard]] explicit operator bool() const; - ~PhotoModifications(); - +enum class RoundedCornersLevel { + Large, + Medium, + Small, + None, }; +[[nodiscard]] float64 RoundedCornersMultiplier(RoundedCornersLevel level); + struct EditorData { enum class CropType { Rect, @@ -35,6 +32,21 @@ struct EditorData { QSize exactSize; CropType cropType = CropType::Rect; bool keepAspectRatio = false; + bool fixedCrop = false; +}; + +struct PhotoModifications { + int angle = 0; + bool flipped = false; + QRect crop; + EditorData::CropType cropType = EditorData::CropType::Rect; + RoundedCornersLevel cornersLevel = RoundedCornersLevel::Large; + std::shared_ptr paint = nullptr; + + [[nodiscard]] bool empty() const; + [[nodiscard]] explicit operator bool() const; + ~PhotoModifications(); + }; [[nodiscard]] QImage ImageModified( diff --git a/Telegram/SourceFiles/editor/photo_editor_content.cpp b/Telegram/SourceFiles/editor/photo_editor_content.cpp index fb9102b114..dd9f701fd8 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_content.cpp @@ -13,6 +13,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "media/view/media_view_pip.h" #include "storage/storage_media_prepare.h" +#include +#include + namespace Editor { using Media::View::FlipSizeByRotation; @@ -26,6 +29,7 @@ PhotoEditorContent::PhotoEditorContent( EditorData data) : RpWidget(parent) , _photoSize(photo->size()) +, _fixedCrop(data.fixedCrop) , _paint(base::make_unique_q( this, modifications, @@ -42,7 +46,8 @@ PhotoEditorContent::PhotoEditorContent( auto result = img.copy(pixelRect.intersected(img.rect())); result.setDevicePixelRatio(dpr); return result; - })) + }, + data.fixedCrop)) , _crop(base::make_unique_q( this, modifications, @@ -91,6 +96,7 @@ PhotoEditorContent::PhotoEditorContent( geometry + _crop->cropMargins(), mods.angle, mods.flipped, imageSizeF); + _crop->setCornersLevel(mods.cornersLevel); _paint->applyTransform(geometry, mods.angle, mods.flipped); _innerRect = geometry; @@ -110,6 +116,48 @@ PhotoEditorContent::PhotoEditorContent( }, lifetime()); setupDragArea(); + + if (_fixedCrop) { + const auto pan = _crop->lifetime().make_state< + std::optional + >(); + _crop->events( + ) | rpl::on_next([=](not_null e) { + const auto type = e->type(); + if (type == QEvent::Wheel) { + const auto wheel = static_cast(e.get()); + const auto raw = wheel->angleDelta(); + _paint->zoomSceneItems( + raw.y() ? raw.y() : raw.x(), + wheel->modifiers().testFlag(Qt::ShiftModifier)); + e->accept(); + } else if (type == QEvent::MouseButtonPress) { + const auto mouse = static_cast(e.get()); + if (mouse->button() == Qt::MiddleButton) { + *pan = mouse->pos(); + _crop->setCursor(Qt::ClosedHandCursor); + e->accept(); + } + } else if (type == QEvent::MouseMove) { + if (pan->has_value()) { + const auto mouse = static_cast(e.get()); + const auto point = mouse->pos(); + const auto delta = point - **pan; + *pan = point; + _paint->panSceneItems( + _paint->mapWidgetDeltaToScene(delta)); + e->accept(); + } + } else if (type == QEvent::MouseButtonRelease) { + const auto mouse = static_cast(e.get()); + if (mouse->button() == Qt::MiddleButton && pan->has_value()) { + pan->reset(); + _crop->unsetCursor(); + e->accept(); + } + } + }, _crop->lifetime()); + } } void PhotoEditorContent::applyModifications( @@ -162,6 +210,38 @@ void PhotoEditorContent::applyBrush(const Brush &brush) { _paint->applyBrush(brush); } +void PhotoEditorContent::createTextItem() { + _paint->createTextItem(); +} + +void PhotoEditorContent::clearSelection() { + _paint->clearSelection(); +} + +void PhotoEditorContent::setTextColor(const QColor &color) { + _paint->setTextColor(color); +} + +void PhotoEditorContent::setSelectedTextColor(const QColor &color) { + _paint->setSelectedTextColor(color); +} + +rpl::producer PhotoEditorContent::textColorRequests() const { + return _paint->textColorRequests(); +} + +rpl::producer PhotoEditorContent::textItemSelections() const { + return _paint->textItemSelections(); +} + +rpl::producer<> PhotoEditorContent::textItemDeselections() const { + return _paint->textItemDeselections(); +} + +rpl::producer PhotoEditorContent::textEditStates() const { + return _paint->textEditStates(); +} + bool PhotoEditorContent::handleKeyPress(not_null e) const { return false; } diff --git a/Telegram/SourceFiles/editor/photo_editor_content.h b/Telegram/SourceFiles/editor/photo_editor_content.h index cf1cc3226f..50dbfff3ad 100644 --- a/Telegram/SourceFiles/editor/photo_editor_content.h +++ b/Telegram/SourceFiles/editor/photo_editor_content.h @@ -31,6 +31,15 @@ public: void applyModifications(PhotoModifications modifications); void applyMode(const PhotoEditorMode &mode); void applyBrush(const Brush &brush); + void createTextItem(); + void clearSelection(); + void setTextColor(const QColor &color); + void setSelectedTextColor(const QColor &color); + + [[nodiscard]] rpl::producer textColorRequests() const; + [[nodiscard]] rpl::producer textItemSelections() const; + [[nodiscard]] rpl::producer<> textItemDeselections() const; + [[nodiscard]] rpl::producer textEditStates() const; void applyAspectRatio(float64 ratio); void save(PhotoModifications &modifications); @@ -45,6 +54,7 @@ public: private: const QSize _photoSize; + const bool _fixedCrop = false; const base::unique_qptr _paint; const base::unique_qptr _crop; const std::shared_ptr _photo; diff --git a/Telegram/SourceFiles/editor/photo_editor_controls.cpp b/Telegram/SourceFiles/editor/photo_editor_controls.cpp index f090b3c5d2..5d10465d10 100644 --- a/Telegram/SourceFiles/editor/photo_editor_controls.cpp +++ b/Telegram/SourceFiles/editor/photo_editor_controls.cpp @@ -12,6 +12,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/image/image_prepare.h" #include "ui/widgets/buttons.h" #include "ui/widgets/labels.h" +#include "ui/widgets/menu/menu_multiline_action.h" #include "ui/widgets/popup_menu.h" #include "ui/wrap/fade_wrap.h" #include "ui/painter.h" @@ -202,6 +203,38 @@ ButtonBar::ButtonBar( }, lifetime()); } +class TextToolButton final : public Ui::AbstractButton { +public: + TextToolButton(not_null parent) + : AbstractButton(parent) { + constexpr auto kSizeShrink = 6; + resize( + st::photoEditorStickersButton.width - kSizeShrink, + st::photoEditorStickersButton.height - kSizeShrink); + events( + ) | rpl::on_next([=](not_null event) { + if (event->type() == QEvent::Enter + || event->type() == QEvent::Leave) { + update(); + } + }, lifetime()); + } + +private: + void paintEvent(QPaintEvent *) override { + auto p = QPainter(this); + auto hq = PainterHighQualityEnabler(p); + auto font = st::semiboldFont->f; + font.setPixelSize(QWidget::rect().height() / 2); + p.setFont(font); + p.setPen(isOver() + ? st::photoEditorButtonIconFgOver + : st::photoEditorButtonIconFg); + p.translate(0, st::lineWidth * 3); + p.drawText(QWidget::rect(), style::al_center, u"A"_q); + } +}; + PhotoEditorControls::PhotoEditorControls( not_null parent, std::shared_ptr controllers, @@ -244,6 +277,11 @@ PhotoEditorControls::PhotoEditorControls( : base::make_unique_q( _transformButtons, st::photoEditorCropRatioButton)) +, _cornersButton((data.cropType == EditorData::CropType::RoundedRect) + ? base::make_unique_q( + _transformButtons, + st::photoEditorCornersButton) + : nullptr) , _transformDone(base::make_unique_q( _transformButtons, (data.confirm.isEmpty() ? tr::lng_box_done(tr::now) : data.confirm), @@ -272,6 +310,7 @@ PhotoEditorControls::PhotoEditorControls( _paintBottomButtons, st::photoEditorStickersButton) : nullptr) +, _textButton(base::make_unique_q(_paintBottomButtons)) , _paintDone(base::make_unique_q( _paintBottomButtons, tr::lng_box_done(tr::now), @@ -474,6 +513,7 @@ PhotoEditorControls::PhotoEditorControls( add(tr::lng_photo_editor_crop_square(tr::now), 1.); add(u"3:2"_q, 3. / 2.); add(u"16:9"_q, 16. / 9.); + add(u"3:4"_q, 3. / 4.); add(u"9:16"_q, 9. / 16.); add(tr::lng_photo_editor_crop_free(tr::now), 0.); const auto button = _cropRatioButton.get(); @@ -483,6 +523,69 @@ PhotoEditorControls::PhotoEditorControls( }); } + _currentCornersLevel = modifications.cornersLevel; + if (_cornersButton) { + const auto updateIcon = [=] { + const auto active = (_currentCornersLevel + != RoundedCornersLevel::Large); + const auto icon = active + ? &st::photoEditorCornersIconActive + : nullptr; + _cornersButton->setIconOverride(icon, icon); + }; + updateIcon(); + _cornersButton->setClickedCallback([=] { + _cornersMenu = base::make_unique_q( + _cornersButton.get(), + st::photoEditorCropRatioMenu); + _cornersMenu->setForcedOrigin( + Ui::PanelAnimation::Origin::BottomRight); + auto about = base::make_unique_q( + _cornersMenu->menu(), + _cornersMenu->menu()->st(), + st::photoEditorCornersMenuAboutLabel, + st::photoEditorCornersMenuAboutPosition, + TextWithEntities{ + tr::lng_photo_editor_corners_about(tr::now), + }); + _cornersMenu->addAction(std::move(about)); + _cornersMenu->addSeparator(); + const auto check = &st::mediaPlayerMenuCheck; + const auto add = [&]( + const QString &text, + RoundedCornersLevel level) { + const auto selected = (_currentCornersLevel == level); + _cornersMenu->addAction( + text, + [=] { + if (_currentCornersLevel == level) { + return; + } + _currentCornersLevel = level; + updateIcon(); + _cornersLevelChanges.fire_copy(level); + }, + selected ? check : nullptr); + }; + add( + tr::lng_photo_editor_corners_large(tr::now), + RoundedCornersLevel::Large); + add( + tr::lng_photo_editor_corners_medium(tr::now), + RoundedCornersLevel::Medium); + add( + tr::lng_photo_editor_corners_small(tr::now), + RoundedCornersLevel::Small); + add( + tr::lng_photo_editor_corners_none(tr::now), + RoundedCornersLevel::None); + const auto button = _cornersButton.get(); + const auto bottomRight = button->mapToGlobal( + QPoint(button->width(), 0)); + _cornersMenu->popup(bottomRight); + }); + } + updateInputMask(); } @@ -499,6 +602,10 @@ rpl::producer<> PhotoEditorControls::paintModeRequests() const { return _paintModeButton->clicks() | rpl::to_empty; } +rpl::producer<> PhotoEditorControls::textRequests() const { + return _textButton->clicks() | rpl::to_empty; +} + rpl::producer<> PhotoEditorControls::doneRequests() const { return rpl::merge( _transformDone->clicks() | rpl::to_empty, @@ -527,6 +634,11 @@ rpl::producer PhotoEditorControls::aspectRatioChanges() const { return _aspectRatioChanges.events(); } +auto PhotoEditorControls::cornersLevelChanges() const +-> rpl::producer { + return _cornersLevelChanges.events(); +} + int PhotoEditorControls::bottomButtonsTop() const { return height() - st::photoEditorControlsBottomSkip diff --git a/Telegram/SourceFiles/editor/photo_editor_controls.h b/Telegram/SourceFiles/editor/photo_editor_controls.h index 24d95fa799..404a64d819 100644 --- a/Telegram/SourceFiles/editor/photo_editor_controls.h +++ b/Telegram/SourceFiles/editor/photo_editor_controls.h @@ -15,6 +15,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/photo_editor_inner_common.h" namespace Ui { +class AbstractButton; class IconButton; class FlatLabel; class PopupMenu; @@ -41,11 +42,14 @@ public: [[nodiscard]] rpl::producer rotateRequests() const; [[nodiscard]] rpl::producer<> flipRequests() const; [[nodiscard]] rpl::producer<> paintModeRequests() const; + [[nodiscard]] rpl::producer<> textRequests() const; [[nodiscard]] rpl::producer<> doneRequests() const; [[nodiscard]] rpl::producer<> cancelRequests() const; [[nodiscard]] rpl::producer colorLinePositionValue() const; [[nodiscard]] rpl::producer colorLineShownValue() const; [[nodiscard]] rpl::producer aspectRatioChanges() const; + [[nodiscard]] auto cornersLevelChanges() const + -> rpl::producer; [[nodiscard]] bool animating() const; @@ -75,6 +79,7 @@ private: const base::unique_qptr _rotateButton; const base::unique_qptr _paintModeButton; const base::unique_qptr _cropRatioButton; + const base::unique_qptr _cornersButton; const base::unique_qptr _transformDone; const base::unique_qptr _paintCancel; @@ -82,10 +87,13 @@ private: const base::unique_qptr _redoButton; const base::unique_qptr _paintModeButtonActive; const base::unique_qptr _stickersButton; + const base::unique_qptr _textButton; const base::unique_qptr _paintDone; base::unique_qptr _ratioMenu; + base::unique_qptr _cornersMenu; float64 _currentRatio = 0.; + RoundedCornersLevel _currentCornersLevel = RoundedCornersLevel::Large; bool _flipped = false; @@ -94,6 +102,7 @@ private: rpl::variable _mode; rpl::event_stream> _keyPresses; rpl::event_stream _aspectRatioChanges; + rpl::event_stream _cornersLevelChanges; }; diff --git a/Telegram/SourceFiles/editor/scene/scene.cpp b/Telegram/SourceFiles/editor/scene/scene.cpp index d4d8463ff9..871a20bb92 100644 --- a/Telegram/SourceFiles/editor/scene/scene.cpp +++ b/Telegram/SourceFiles/editor/scene/scene.cpp @@ -10,11 +10,18 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "editor/scene/scene_item_canvas.h" #include "editor/scene/scene_item_line.h" #include "editor/scene/scene_item_sticker.h" +#include "editor/scene/scene_item_text.h" +#include "editor/scene/scene_emoji_document.h" #include "ui/image/image_prepare.h" #include "ui/rp_widget.h" #include "styles/style_editor.h" +#include #include +#include +#include +#include +#include namespace Editor { namespace { @@ -100,6 +107,50 @@ bool SkipMouseEvent(not_null event) { return event->isAccepted() || (event->button() == Qt::RightButton); } +constexpr auto kPaddingFactor = 0.4; +constexpr auto kMaxWidthFactor = 0.8; +constexpr auto kMinWidthFactor = 0.16; +constexpr auto kIdealWidthExtra = 2; +constexpr auto kDefaultFontSizeDivisor = 15.; +constexpr auto kScaleThreshold = 0.01; + +class TextEditProxy final : public QGraphicsTextItem { +public: + using QGraphicsTextItem::QGraphicsTextItem; + + Fn onFinish; + Fn onCancel; + +protected: + void keyPressEvent(QKeyEvent *event) override { + if (event->key() == Qt::Key_Escape) { + fire(onCancel); + return; + } + QGraphicsTextItem::keyPressEvent(event); + } + + void focusOutEvent(QFocusEvent *event) override { + QGraphicsTextItem::focusOutEvent(event); + fire(onFinish); + } + + void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override { + event->accept(); + } + +private: + void fire(Fn &callback) { + if (!callback) { + return; + } + const auto cb = std::exchange(callback, nullptr); + onFinish = nullptr; + onCancel = nullptr; + crl::on_main(cb); + } +}; + } // namespace Scene::Scene(const QRectF &rect) @@ -237,9 +288,34 @@ Scene::Scene(const QRectF &rect) addItem(item); _canvas->setZValue(++_lastLineZ); }, _lifetime); + + QObject::connect( + this, + &QGraphicsScene::selectionChanged, + [=] { + const auto selected = selectedItems(); + auto *textItem = (ItemText*)(nullptr); + if (selected.size() == 1 + && selected.front()->type() == ItemText::Type) { + textItem = static_cast(selected.front()); + } + const auto changed = (textItem != _selectedTextItem); + if (!changed) { + return; + } + _selectedTextItem = textItem; + if (textItem) { + _textItemSelections.fire_copy(textItem->color()); + } else { + _textItemDeselections.fire({}); + } + }); } void Scene::cancelDrawing() { + if (_textEdit.proxy) { + finishTextEditing(false); + } _canvas->cancelDrawing(); } @@ -271,6 +347,16 @@ void Scene::removeItem(const ItemPtr &item) { } void Scene::mousePressEvent(QGraphicsSceneMouseEvent *event) { + if (_textEdit.proxy) { + const auto clickOnProxy = _textEdit.proxy->contains( + _textEdit.proxy->mapFromScene(event->scenePos())); + if (!clickOnProxy) { + finishTextEditing(true); + QGraphicsScene::mousePressEvent(event); + return; + } + } + QGraphicsScene::mousePressEvent(event); if (SkipMouseEvent(event) || !sceneRect().contains(event->scenePos())) { return; @@ -280,7 +366,7 @@ void Scene::mousePressEvent(QGraphicsSceneMouseEvent *event) { void Scene::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { QGraphicsScene::mouseReleaseEvent(event); - if (SkipMouseEvent(event)) { + if (SkipMouseEvent(event) || _textEdit.proxy) { return; } _canvas->handleMouseReleaseEvent(event); @@ -288,16 +374,58 @@ void Scene::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { void Scene::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { QGraphicsScene::mouseMoveEvent(event); - if (SkipMouseEvent(event)) { + if (SkipMouseEvent(event) || _textEdit.proxy) { return; } _canvas->handleMouseMoveEvent(event); } -void Scene::applyBrush(const QColor &color, float size, Brush::Tool tool) { +void Scene::applyBrush(const QColor &color, float64 size, Brush::Tool tool) { _canvas->applyBrush(color, size, tool); } +void Scene::setTextDefaults( + const QColor &color, + float64 fontSize, + int style) { + _textColor = color; + _textFontSize = fontSize; + _textStyle = style; +} + +void Scene::setTextColor(const QColor &color) { + _textColor = color; + if (_textEdit.proxy) { + _textEdit.proxy->setDefaultTextColor(EffectiveTextColor( + color, + static_cast(_textEditStyle))); + } +} + +void Scene::setSelectedTextColor(const QColor &color) { + for (auto *item : selectedItems()) { + if (item->type() == ItemText::Type) { + static_cast(item)->setColor(color); + } + } +} + +rpl::producer Scene::textColorRequests() const { + return _textColorRequests.events(); +} + +rpl::producer Scene::textItemSelections() const { + return _textItemSelections.events(); +} + +rpl::producer<> Scene::textItemDeselections() const { + return _textItemDeselections.events(); +} + +rpl::producer Scene::textEditStates() const { + return _textEditStates.events(); +} + void Scene::setBlurSource(Fn source) { _blurSource = std::move(source); } @@ -328,6 +456,7 @@ std::shared_ptr Scene::lastZ() const { } void Scene::updateZoom(float64 zoom) { + _currentZoom = zoom; _canvas->updateZoom(zoom); for (const auto &item : items()) { if (item->type() >= ItemBase::Type) { @@ -396,6 +525,10 @@ void Scene::clearRedoList() { } void Scene::save(SaveState state) { + if (_textEdit.proxy) { + finishTextEditing(true); + } + removeIf([](const ItemPtr &item) { return item->isRemovedStatus() && !item->hasState(SaveState::Keep) @@ -421,11 +554,284 @@ void Scene::restore(SaveState state) { cancelDrawing(); } +void Scene::setTextEditing(bool editing) { + if (_textEditing == editing) { + return; + } + _textEditing = editing; + _textEditStates.fire_copy(editing); +} + +void Scene::setupTextProxy( + QGraphicsTextItem *proxy, + const QColor &color, + float64 fontSize) { + proxy->setTextInteractionFlags(Qt::TextEditorInteraction); + proxy->setDefaultTextColor(color); + + auto *emojiDoc = new EmojiDocument(proxy); + emojiDoc->setDocumentMargin(0); + proxy->setDocument(emojiDoc); + + auto font = QFont(); + font.setPixelSize(int(fontSize)); + font.setWeight(QFont::DemiBold); + proxy->setFont(font); + + { + auto option = emojiDoc->defaultTextOption(); + option.setAlignment(Qt::AlignCenter); + emojiDoc->setDefaultTextOption(option); + } +} + +void Scene::createTextAtCenter() { + if (_textEdit.proxy) { + return; + } + + const auto generation = ++_textEditGeneration; + + clearSelection(); + cancelDrawing(); + setTextEditing(true); + _textEditStyle = _textStyle; + + _textEdit.proxy.reset(new TextEditProxy()); + const auto proxy = _textEdit.proxy.get(); + setupTextProxy( + proxy, + EffectiveTextColor( + _textColor, + static_cast(_textEditStyle)), + _textFontSize); + + const auto emojiDoc = proxy->document(); + const auto shortSide = std::min( + sceneRect().width(), + sceneRect().height()); + const auto padding = int(_textFontSize * kPaddingFactor); + const auto maxTextWidth = std::max( + int(shortSide * kMaxWidthFactor) - 2 * padding, + 1); + const auto minTextWidth = std::clamp( + int(shortSide * kMinWidthFactor) - 2 * padding, + 1, + maxTextWidth); + const auto sceneCenter = sceneRect().center(); + const auto adjustWidth = [=] { + emojiDoc->setTextWidth(maxTextWidth); + const auto ideal = int(std::ceil(emojiDoc->idealWidth())); + const auto width = std::clamp( + ideal + kIdealWidthExtra, + minTextWidth, + maxTextWidth); + proxy->setTextWidth(width); + proxy->setPos(sceneCenter.x() - width / 2., sceneCenter.y()); + }; + adjustWidth(); + + QObject::connect(emojiDoc, &QTextDocument::contentsChanged, [=] { + ReplaceEmoji(emojiDoc); + adjustWidth(); + }); + + QGraphicsScene::addItem(proxy); + proxy->setZValue((*_lastZ)++); + proxy->setFocus(); + if (!views().isEmpty()) { + views().first()->setFocus(); + } + + const auto raw = static_cast(proxy); + raw->onFinish = crl::guard(this, [=] { + if (generation == _textEditGeneration) { + finishTextEditing(true); + } + }); + raw->onCancel = crl::guard(this, [=] { + if (generation == _textEditGeneration) { + finishTextEditing(false); + } + }); + + _textEdit.item.reset(); + _textColorRequests.fire_copy(_textColor); +} + +void Scene::startTextEditing(ItemText *item) { + if (_textEdit.proxy) { + finishTextEditing(true); + } + if (!item) { + return; + } + + const auto generation = ++_textEditGeneration; + + cancelDrawing(); + setTextEditing(true); + _textEditStyle = int(item->textStyle()); + + _textEdit.proxy.reset(new TextEditProxy()); + const auto proxy = _textEdit.proxy.get(); + setupTextProxy( + proxy, + EffectiveTextColor(item->color(), item->textStyle()), + item->fontSize()); + + proxy->setPlainText(item->text()); + ReplaceEmoji(proxy->document()); + + const auto emojiDoc = proxy->document(); + const auto shortSide = std::min( + sceneRect().width(), + sceneRect().height()); + const auto padding = int(item->fontSize() * kPaddingFactor); + const auto maxTextWidth = std::max( + int(shortSide * kMaxWidthFactor) - 2 * padding, + 1); + const auto minTextWidth = std::clamp( + int(shortSide * kMinWidthFactor) - 2 * padding, + 1, + maxTextWidth); + const auto anchor = item->scenePos(); + const auto adjustWidth = [=] { + emojiDoc->setTextWidth(maxTextWidth); + const auto ideal = int(std::ceil(emojiDoc->idealWidth())); + const auto width = std::clamp( + ideal + kIdealWidthExtra, + minTextWidth, + maxTextWidth); + proxy->setTextWidth(width); + const auto center = proxy->boundingRect().center(); + proxy->setTransformOriginPoint(center); + proxy->setPos(anchor - center); + }; + adjustWidth(); + + QObject::connect(emojiDoc, &QTextDocument::contentsChanged, [=] { + ReplaceEmoji(emojiDoc); + adjustWidth(); + }); + + const auto scale = item->editScale(); + proxy->setRotation(item->rotation()); + if (std::abs(scale - 1.) > kScaleThreshold) { + proxy->setScale(scale); + } + + QGraphicsScene::addItem(proxy); + proxy->setZValue((*_lastZ)++); + proxy->setFocus(); + + auto cursor = proxy->textCursor(); + cursor.select(QTextCursor::Document); + proxy->setTextCursor(cursor); + + item->setVisible(false); + + const auto raw = static_cast(proxy); + raw->onFinish = crl::guard(this, [=] { + if (generation == _textEditGeneration) { + finishTextEditing(true); + } + }); + raw->onCancel = crl::guard(this, [=] { + if (generation == _textEditGeneration) { + finishTextEditing(false); + } + }); + + const auto it = _itemsByPointer.find(item); + _textEdit.item = (it != end(_itemsByPointer)) + ? it->second + : std::weak_ptr(); + _textColorRequests.fire_copy(item->color()); +} + +void Scene::finishTextEditing(bool save) { + if (!_textEdit.proxy) { + return; + } + + const auto text = save + ? RecoverTextFromDocument(_textEdit.proxy->document()).trimmed() + : QString(); + const auto proxyRect = _textEdit.proxy->boundingRect(); + const auto proxyCenter = _textEdit.proxy->pos() + + QPointF(proxyRect.width() / 2., proxyRect.height() / 2.); + const auto lockedItem = _textEdit.item.lock(); + auto *existingItem = lockedItem + ? static_cast(lockedItem.get()) + : (ItemText*)(nullptr); + + const auto raw = static_cast(_textEdit.proxy.get()); + raw->onFinish = nullptr; + raw->onCancel = nullptr; + QGraphicsScene::removeItem(_textEdit.proxy.get()); + _textEdit.proxy = nullptr; + _textEdit.item.reset(); + setTextEditing(false); + + const auto defaultStyle = static_cast(_textStyle); + + if (!text.isEmpty()) { + if (existingItem) { + existingItem->setText(text); + existingItem->setVisible(true); + } else { + const auto imageSize = sceneRect().size().toSize(); + const auto contentSize = ItemText::computeContentSize( + text, + _textFontSize, + imageSize, + defaultStyle); + const auto zoom = (_currentZoom > 0.) ? _currentZoom : 1.; + const auto handleInflate = int( + std::ceil(st::photoEditorItemHandleSize / zoom)); + const auto size = std::max( + contentSize.width() + handleInflate, + 1); + auto data = ItemBase::Data{ + .initialZoom = zoom, + .zPtr = _lastZ, + .size = size, + .x = int(proxyCenter.x()), + .y = int(proxyCenter.y()), + .imageSize = imageSize, + }; + auto item = std::make_shared( + text, + _textColor, + _textFontSize, + defaultStyle, + imageSize, + std::move(data)); + addItem(item); + } + } else if (existingItem) { + if (save) { + removeItem(existingItem); + } else { + existingItem->setVisible(true); + } + } +} + Scene::~Scene() { - // Prevent destroying by scene of all items. + disconnect(this, &QGraphicsScene::selectionChanged, nullptr, nullptr); + if (_textEdit.proxy) { + setTextEditing(false); + const auto raw = static_cast( + _textEdit.proxy.get()); + raw->onFinish = nullptr; + raw->onCancel = nullptr; + QGraphicsScene::removeItem(_textEdit.proxy.get()); + _textEdit.proxy = nullptr; + } QGraphicsScene::removeItem(_canvas.get()); for (const auto &item : items()) { - // Scene loses ownership of an item. QGraphicsScene::removeItem(item.get()); } } diff --git a/Telegram/SourceFiles/editor/scene/scene.h b/Telegram/SourceFiles/editor/scene/scene.h index 5b37b2d794..5b4a31657e 100644 --- a/Telegram/SourceFiles/editor/scene/scene.h +++ b/Telegram/SourceFiles/editor/scene/scene.h @@ -13,6 +13,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include class QGraphicsSceneMouseEvent; +class QGraphicsTextItem; namespace Ui { class RpWidget; @@ -21,6 +22,7 @@ class RpWidget; namespace Editor { class ItemCanvas; +class ItemText; class NumberedItem; class Scene final : public QGraphicsScene { @@ -29,8 +31,9 @@ public: Scene(const QRectF &rect); ~Scene(); - void applyBrush(const QColor &color, float size, Brush::Tool tool); + void applyBrush(const QColor &color, float64 size, Brush::Tool tool); void setBlurSource(Fn source); + void setTextDefaults(const QColor &color, float64 fontSize, int style); [[nodiscard]] std::vector items( Qt::SortOrder order = Qt::DescendingOrder) const; @@ -46,6 +49,16 @@ public: void cancelDrawing(); + void startTextEditing(ItemText *item); + void createTextAtCenter(); + void setTextColor(const QColor &color); + void setSelectedTextColor(const QColor &color); + + [[nodiscard]] rpl::producer textColorRequests() const; + [[nodiscard]] rpl::producer textItemSelections() const; + [[nodiscard]] rpl::producer<> textItemDeselections() const; + [[nodiscard]] rpl::producer textEditStates() const; + [[nodiscard]] bool hasUndo() const; [[nodiscard]] bool hasRedo() const; @@ -62,6 +75,13 @@ protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; private: void removeIf(Fn proj); + void finishTextEditing(bool save); + void setTextEditing(bool editing); + void setupTextProxy( + QGraphicsTextItem *proxy, + const QColor &color, + float64 fontSize); + const std::shared_ptr _canvas; const std::shared_ptr _lastZ; Fn _blurSource; @@ -70,9 +90,27 @@ private: std::unordered_map _itemsByPointer; float64 _lastLineZ = 0.; + float64 _currentZoom = 1.; int _itemNumber = 0; + QColor _textColor; + float64 _textFontSize = 0.; + int _textStyle = 0; + int _textEditStyle = 0; + + struct { + std::weak_ptr item; + base::unique_qptr proxy; + } _textEdit; + rpl::event_stream<> _addsItem, _removesItem; + rpl::event_stream _textColorRequests; + rpl::event_stream _textItemSelections; + rpl::event_stream<> _textItemDeselections; + rpl::event_stream _textEditStates; + ItemText *_selectedTextItem = nullptr; + bool _textEditing = false; + int _textEditGeneration = 0; rpl::lifetime _lifetime; }; diff --git a/Telegram/SourceFiles/editor/scene/scene_emoji_document.cpp b/Telegram/SourceFiles/editor/scene/scene_emoji_document.cpp new file mode 100644 index 0000000000..df04598343 --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_emoji_document.cpp @@ -0,0 +1,132 @@ +/* +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 "editor/scene/scene_emoji_document.h" + +#include "ui/emoji_config.h" +#include "ui/painter.h" + +#include +#include + +namespace Editor { + +EmojiDocument::EmojiDocument(QObject *parent) +: QTextDocument(parent) { +} + +QVariant EmojiDocument::loadResource(int type, const QUrl &name) { + if (type != QTextDocument::ImageResource + || name.scheme() != u"emoji"_q) { + return QTextDocument::loadResource(type, name); + } + const auto i = _cache.find(name); + if (i != _cache.end()) { + return i->second; + } + auto result = QVariant(); + if (const auto emoji = Ui::Emoji::FromUrl(name.toDisplayString())) { + const auto factor = style::DevicePixelRatio(); + const auto logical = QFontMetrics(defaultFont()).height(); + const auto source = Ui::Emoji::GetSizeLarge(); + auto image = QImage( + QSize(logical, logical) * factor, + QImage::Format_ARGB32_Premultiplied); + image.setDevicePixelRatio(factor); + image.fill(Qt::transparent); + { + auto p = QPainter(&image); + auto hq = PainterHighQualityEnabler(p); + const auto sourceLogical = source / float64(factor); + const auto scale = logical / sourceLogical; + p.scale(scale, scale); + Ui::Emoji::Draw(p, emoji, source, 0, 0); + } + result = QVariant(QPixmap::fromImage(std::move(image))); + } + _cache.emplace(name, result); + return result; +} + +void ReplaceEmoji(QTextDocument *doc) { + QSignalBlocker blocker(doc); + const auto fontHeight = QFontMetrics(doc->defaultFont()).height(); + auto cursor = QTextCursor(doc); + auto block = doc->begin(); + while (block.isValid()) { + auto text = block.text(); + auto start = text.constData(); + auto end = start + text.size(); + auto ch = start; + while (ch < end) { + auto emojiLength = 0; + const auto emoji = Ui::Emoji::Find(ch, end, &emojiLength); + if (!emoji || emojiLength <= 0) { + ++ch; + continue; + } + const auto pos = block.position() + int(ch - start); + cursor.setPosition(pos); + cursor.setPosition( + pos + emojiLength, + QTextCursor::KeepAnchor); + + auto format = QTextImageFormat(); + format.setName(emoji->toUrl()); + format.setWidth(fontHeight); + format.setHeight(fontHeight); + format.setVerticalAlignment( + QTextCharFormat::AlignBaseline); + cursor.insertImage(format); + + block = doc->findBlock(pos); + text = block.text(); + start = text.constData(); + end = start + text.size(); + ch = start + (pos - block.position()) + 1; + continue; + } + block = block.next(); + } +} + +QString RecoverTextFromDocument(QTextDocument *doc) { + auto result = QString(); + auto block = doc->begin(); + while (block.isValid()) { + if (block != doc->begin()) { + result += '\n'; + } + auto it = block.begin(); + while (!it.atEnd()) { + const auto fragment = it.fragment(); + if (!fragment.isValid()) { + ++it; + continue; + } + const auto text = fragment.text(); + const auto format = fragment.charFormat(); + for (const auto &ch : text) { + if (ch == QChar::ObjectReplacementCharacter) { + if (format.isImageFormat()) { + const auto name = format.toImageFormat().name(); + if (const auto emoji = Ui::Emoji::FromUrl(name)) { + result += emoji->text(); + continue; + } + } + } + result += ch; + } + ++it; + } + block = block.next(); + } + return result; +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_emoji_document.h b/Telegram/SourceFiles/editor/scene/scene_emoji_document.h new file mode 100644 index 0000000000..eff28e4907 --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_emoji_document.h @@ -0,0 +1,26 @@ +/* +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 + +namespace Editor { + +class EmojiDocument final : public QTextDocument { +public: + explicit EmojiDocument(QObject *parent = nullptr); + QVariant loadResource(int type, const QUrl &name) override; + +private: + std::map _cache; +}; + +void ReplaceEmoji(QTextDocument *doc); +[[nodiscard]] QString RecoverTextFromDocument(QTextDocument *doc); + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_base.cpp b/Telegram/SourceFiles/editor/scene/scene_item_base.cpp index 8c69702584..3f29567ad6 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_base.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_base.cpp @@ -12,6 +12,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/popup_menu.h" #include "ui/painter.h" #include "styles/style_editor.h" +#include "styles/style_media_view.h" #include "styles/style_menu_icons.h" #include @@ -211,22 +212,22 @@ void ItemBase::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { _menu = base::make_unique_q( nullptr, - st::popupMenuWithIcons); + st::mediaviewPopupMenu); add( tr::lng_photo_editor_menu_delete, kDeleteSequence, [=] { actionDelete(); }, - &st::menuIconDelete); + &st::mediaMenuIconDelete); add( tr::lng_photo_editor_menu_flip, kFlipSequence, [=] { actionFlip(); }, - &st::menuIconFlip); + &st::mediaMenuIconFlip); add( tr::lng_photo_editor_menu_duplicate, kDuplicateSequence, [=] { actionDuplicate(); }, - &st::menuIconCopy); + &st::mediaMenuIconCopy); _menu->popup(event->screenPos()); } @@ -329,6 +330,7 @@ void ItemBase::updateVerticalSize() { } void ItemBase::setAspectRatio(float64 aspectRatio) { + prepareGeometryChange(); _aspectRatio = aspectRatio; updateVerticalSize(); } diff --git a/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp b/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp index 5e7cbb91b5..27a4186dee 100644 --- a/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp +++ b/Telegram/SourceFiles/editor/scene/scene_item_canvas.cpp @@ -257,7 +257,7 @@ void ItemCanvas::drawArrowHead() { } direction /= length; const auto angle = qDegreesToRadians( - double(st::photoEditorArrowHeadAngleDegrees)); + float64(st::photoEditorArrowHeadAngleDegrees)); const auto sinA = std::sin(angle); const auto cosA = std::cos(angle); const auto rotate = [&](const QPointF &v, float64 s, float64 c) { diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.cpp b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp new file mode 100644 index 0000000000..834375157a --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.cpp @@ -0,0 +1,670 @@ +/* +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 "editor/scene/scene_item_text.h" + +#include "editor/scene/scene.h" +#include "editor/scene/scene_emoji_document.h" +#include "lang/lang_keys.h" +#include "ui/emoji_config.h" +#include "ui/painter.h" +#include "ui/widgets/popup_menu.h" +#include "styles/style_editor.h" +#include "styles/style_media_view.h" +#include "styles/style_menu_icons.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace Editor { +namespace { + +constexpr auto kPaddingFactor = 0.4; +constexpr auto kMaxWidthFactor = 0.8; +constexpr auto kMinContentWidth = 20; +constexpr auto kBrightnessFramedThreshold = 0.721; +constexpr auto kBrightnessSemiTransparentThreshold = 0.25; +constexpr auto kSemiTransparentAlpha = 0x99; +constexpr auto kCornerRadiusFactor = 1. / 3.; +constexpr auto kLinePadHFactor = 1. / 3.; +constexpr auto kLinePadVFactor = 1. / 8.; +constexpr auto kMergeRadiusFactor = 1.5; +constexpr auto kLineShiftFactor = 1. / 7.; + +struct LayoutMetrics { + int contentWidth = 0; + int contentHeight = 0; + int padding = 0; + int textMaxWidth = 0; +}; + +QFont TextFont(float64 fontSize) { + auto font = QFont(); + font.setPixelSize(std::max(int(fontSize), 1)); + font.setWeight(QFont::DemiBold); + return font; +} + +float64 ComputeBrightness(const QColor &color) { + return (color.red() * 0.2126 + + color.green() * 0.7152 + + color.blue() * 0.0722) / 255.; +} + +LayoutMetrics ComputeMetrics( + const QString &text, + float64 fontSize, + const QSize &imageSize, + TextStyle style) { + const auto hasBackground = (style == TextStyle::Framed) + || (style == TextStyle::SemiTransparent); + const auto padding = hasBackground ? int(fontSize * kPaddingFactor) : 0; + const auto shortSide = std::min(imageSize.width(), imageSize.height()); + const auto textMaxWidth = std::max( + int(shortSide * kMaxWidthFactor) - 2 * padding, + kMinContentWidth); + + const auto font = TextFont(fontSize); + + auto processedText = text; + processedText.replace('\n', QChar::LineSeparator); + + auto option = QTextOption(); + option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + + auto layout = QTextLayout(processedText, font); + layout.setTextOption(option); + layout.beginLayout(); + + auto totalHeight = 0.; + auto maxWidth = 0.; + while (true) { + auto line = layout.createLine(); + if (!line.isValid()) { + break; + } + line.setLineWidth(textMaxWidth); + line.setPosition(QPointF(0, totalHeight)); + totalHeight += line.height(); + maxWidth = std::max(maxWidth, float64(line.naturalTextWidth())); + } + layout.endLayout(); + + return { + .contentWidth = std::max(int(std::ceil(maxWidth)), kMinContentWidth), + .contentHeight = int(std::ceil(totalHeight)), + .padding = padding, + .textMaxWidth = textMaxWidth, + }; +} + +struct LineRect { + float64 left = 0; + float64 top = 0; + float64 right = 0; + float64 bottom = 0; + [[nodiscard]] float64 width() const { return right - left; } +}; + +QPainterPath BuildConnectedBackground( + const QTextLayout &layout, + int contentWidth, + int padding, + float64 fontSize) { + const auto linePadH = fontSize * kLinePadHFactor; + const auto linePadV = fontSize * kLinePadVFactor; + const auto cornerRadius = fontSize * kCornerRadiusFactor; + const auto mergeRadius = cornerRadius * kMergeRadiusFactor; + const auto centerX = padding + contentWidth / 2.; + + auto rects = std::vector(); + for (auto i = 0; i < layout.lineCount(); ++i) { + const auto line = layout.lineAt(i); + const auto hw = float64(line.naturalTextWidth()) / 2. + linePadH; + rects.push_back({ + .left = centerX - hw, + .top = padding + float64(line.y()) - linePadV, + .right = centerX + hw, + .bottom = padding + float64(line.y() + line.height()) + linePadV, + }); + } + + if (rects.empty()) { + return {}; + } + if (rects.size() == 1) { + auto path = QPainterPath(); + const auto &r = rects[0]; + path.addRoundedRect( + QRectF(r.left, r.top, r.width(), r.bottom - r.top), + cornerRadius, + cornerRadius); + return path; + } + + for (auto i = 1; i < int(rects.size()); ++i) { + rects[i - 1].bottom = rects[i].top; + } + + for (auto i = 1; i < int(rects.size()); ++i) { + auto traceback = false; + if (std::abs(rects[i - 1].left - rects[i].left) < mergeRadius) { + const auto v = std::min(rects[i - 1].left, rects[i].left); + rects[i - 1].left = rects[i].left = v; + traceback = true; + } + if (std::abs(rects[i - 1].right - rects[i].right) < mergeRadius) { + const auto v = std::max(rects[i - 1].right, rects[i].right); + rects[i - 1].right = rects[i].right = v; + traceback = true; + } + if (traceback) { + for (auto j = i; j >= 1; --j) { + if (std::abs(rects[j - 1].left - rects[j].left) + < mergeRadius) { + const auto v = std::min( + rects[j - 1].left, + rects[j].left); + rects[j - 1].left = rects[j].left = v; + } + if (std::abs(rects[j - 1].right - rects[j].right) + < mergeRadius) { + const auto v = std::max( + rects[j - 1].right, + rects[j].right); + rects[j - 1].right = rects[j].right = v; + } + } + } + } + + struct V { float64 x, y; }; + auto verts = std::vector(); + + verts.push_back({ rects[0].left, rects[0].top }); + verts.push_back({ rects[0].right, rects[0].top }); + + for (auto i = 1; i < int(rects.size()); ++i) { + if (std::abs(rects[i].right - rects[i - 1].right) > 0.5) { + verts.push_back({ rects[i - 1].right, rects[i].top }); + verts.push_back({ rects[i].right, rects[i].top }); + } + } + + const auto last = int(rects.size()) - 1; + verts.push_back({ rects[last].right, rects[last].bottom }); + verts.push_back({ rects[last].left, rects[last].bottom }); + + for (auto i = last - 1; i >= 0; --i) { + if (std::abs(rects[i].left - rects[i + 1].left) > 0.5) { + verts.push_back({ rects[i + 1].left, rects[i + 1].top }); + verts.push_back({ rects[i].left, rects[i + 1].top }); + } + } + + auto path = QPainterPath(); + const auto n = int(verts.size()); + for (auto i = 0; i < n; ++i) { + const auto &prev = verts[(i + n - 1) % n]; + const auto &curr = verts[i]; + const auto &next = verts[(i + 1) % n]; + + const auto dx1 = curr.x - prev.x; + const auto dy1 = curr.y - prev.y; + const auto len1 = std::sqrt(dx1 * dx1 + dy1 * dy1); + + const auto dx2 = next.x - curr.x; + const auto dy2 = next.y - curr.y; + const auto len2 = std::sqrt(dx2 * dx2 + dy2 * dy2); + + if (len1 < 0.1 || len2 < 0.1) { + if (i == 0) { + path.moveTo(curr.x, curr.y); + } else { + path.lineTo(curr.x, curr.y); + } + continue; + } + + const auto r = std::min({ + cornerRadius, + len1 / 2., + len2 / 2., + }); + const auto bx = curr.x - dx1 / len1 * r; + const auto by = curr.y - dy1 / len1 * r; + const auto ax = curr.x + dx2 / len2 * r; + const auto ay = curr.y + dy2 / len2 * r; + + if (i == 0) { + path.moveTo(bx, by); + } else { + path.lineTo(bx, by); + } + path.quadTo(curr.x, curr.y, ax, ay); + } + path.closeSubpath(); + return path; +} + +} // namespace + +QColor EffectiveTextColor(const QColor &color, TextStyle style) { + if (style != TextStyle::Framed) { + return color; + } + return (ComputeBrightness(color) >= kBrightnessFramedThreshold) + ? QColor(0, 0, 0) + : QColor(255, 255, 255); +} + +ItemText::ItemText( + const QString &text, + const QColor &color, + float64 fontSize, + TextStyle style, + const QSize &imageSize, + ItemBase::Data data) +: ItemBase(std::move(data)) +, _text(text) +, _color(color) +, _fontSize(fontSize) +, _textStyle(style) +, _imageSize(imageSize) { + renderContent(); +} + +void ItemText::renderContent() { + if (_text.isEmpty()) { + _pixmap = QPixmap(); + setAspectRatio(1.); + return; + } + + const auto m = ComputeMetrics(_text, _fontSize, _imageSize, _textStyle); + const auto pixWidth = m.contentWidth + 2 * m.padding; + const auto pixHeight = m.contentHeight + 2 * m.padding; + + const auto font = TextFont(_fontSize); + + auto processedText = _text; + processedText.replace('\n', QChar::LineSeparator); + + auto option = QTextOption(); + option.setWrapMode(QTextOption::WrapAtWordBoundaryOrAnywhere); + + auto layout = QTextLayout(processedText, font); + layout.setTextOption(option); + + struct EmojiPos { + int start = 0; + int length = 0; + EmojiPtr emoji = nullptr; + }; + auto emojiFormats = QVector(); + auto emojiPositions = std::vector(); + { + auto pos = 0; + const auto begin = processedText.constData(); + const auto end = begin + processedText.size(); + while (pos < processedText.size()) { + auto emojiLen = 0; + const auto emoji = Ui::Emoji::Find( + begin + pos, + end, + &emojiLen); + if (emoji && emojiLen > 0) { + auto fmt = QTextCharFormat(); + fmt.setForeground(QColor(0, 0, 0, 0)); + emojiFormats.append({ pos, emojiLen, fmt }); + emojiPositions.push_back({ pos, emojiLen, emoji }); + pos += emojiLen; + } else { + ++pos; + } + } + } + layout.setFormats(emojiFormats); + + layout.beginLayout(); + auto y = 0.; + while (true) { + auto line = layout.createLine(); + if (!line.isValid()) { + break; + } + line.setLineWidth(m.textMaxWidth); + line.setPosition(QPointF(0, y)); + y += line.height(); + } + layout.endLayout(); + + auto textColor = _color; + auto bgColor = QColor(Qt::transparent); + const auto brightness = ComputeBrightness(_color); + const auto hasBackground = + (_textStyle == TextStyle::Framed) + || (_textStyle == TextStyle::SemiTransparent); + + switch (_textStyle) { + case TextStyle::Framed: + bgColor = _color; + textColor = (brightness >= kBrightnessFramedThreshold) + ? QColor(0, 0, 0) + : QColor(255, 255, 255); + break; + case TextStyle::SemiTransparent: + bgColor = (brightness >= kBrightnessSemiTransparentThreshold) + ? QColor(0, 0, 0, kSemiTransparentAlpha) + : QColor(255, 255, 255, kSemiTransparentAlpha); + break; + case TextStyle::Plain: + break; + } + + const auto dpr = style::DevicePixelRatio(); + auto pixmap = QPixmap(QSize(pixWidth, pixHeight) * dpr); + pixmap.setDevicePixelRatio(dpr); + pixmap.fill(Qt::transparent); + + { + auto p = QPainter(&pixmap); + auto hq = PainterHighQualityEnabler(p); + + if (hasBackground) { + const auto bgPath = BuildConnectedBackground( + layout, + m.contentWidth, + m.padding, + _fontSize); + if (_textStyle == TextStyle::SemiTransparent) { + auto opaque = bgColor; + opaque.setAlpha(255); + auto mask = QPixmap(pixmap.size()); + mask.setDevicePixelRatio(dpr); + mask.fill(Qt::transparent); + { + auto mp = QPainter(&mask); + auto mhq = PainterHighQualityEnabler(mp); + mp.setPen(Qt::NoPen); + mp.setBrush(opaque); + mp.drawPath(bgPath); + } + p.setOpacity(bgColor.alphaF()); + p.drawPixmap(0, 0, mask); + p.setOpacity(1.0); + } else { + p.setPen(Qt::NoPen); + p.setBrush(bgColor); + p.drawPath(bgPath); + } + } + + const auto lineShift = _fontSize * kLineShiftFactor; + const auto lineCount = layout.lineCount(); + p.setPen(textColor); + for (auto i = 0; i < lineCount; ++i) { + const auto line = layout.lineAt(i); + const auto xOffset = + (m.contentWidth - line.naturalTextWidth()) / 2.; + const auto yShift = (i < lineCount - 1) ? -lineShift : 0.; + line.draw( + &p, + QPointF(m.padding + xOffset, m.padding + yShift)); + } + + p.setRenderHint(QPainter::SmoothPixmapTransform, true); + const auto factor = style::DevicePixelRatio(); + const auto source = Ui::Emoji::GetSizeLarge(); + const auto sourceLogical = source / float64(factor); + const auto emojiSize = float64(QFontMetrics(font).height()); + const auto emojiScale = emojiSize / sourceLogical; + for (const auto &ep : emojiPositions) { + auto lineIndex = -1; + for (auto i = 0; i < lineCount; ++i) { + const auto line = layout.lineAt(i); + const auto lineStart = line.textStart(); + const auto lineEnd = lineStart + line.textLength(); + if (ep.start >= lineStart && ep.start < lineEnd) { + lineIndex = i; + break; + } + } + if (lineIndex < 0) { + continue; + } + const auto line = layout.lineAt(lineIndex); + const auto lineStart = line.textStart(); + const auto lineEnd = lineStart + line.textLength(); + const auto drawEnd = std::min(ep.start + ep.length, lineEnd); + const auto xOffset = + (m.contentWidth - line.naturalTextWidth()) / 2.; + const auto yShift = (lineIndex < lineCount - 1) + ? -lineShift + : 0.; + const auto x = line.cursorToX(ep.start); + const auto nextX = line.cursorToX(drawEnd); + const auto glyphWidth = float64(nextX - x); + const auto drawX = m.padding + + xOffset + + x + + (glyphWidth - emojiSize) / 2.; + const auto drawY = m.padding + + yShift + + line.y() + + (line.height() - emojiSize) / 2.; + p.save(); + p.translate(drawX, drawY); + p.scale(emojiScale, emojiScale); + Ui::Emoji::Draw(p, ep.emoji, source, 0, 0); + p.restore(); + } + } + + _pixmap = std::move(pixmap); + const auto handleMargin = std::max( + innerRect().width() - contentRect().width(), + 0.); + setAspectRatio( + (pixHeight + handleMargin) / float64(pixWidth + handleMargin)); +} + +QSize ItemText::computeContentSize( + const QString &text, + float64 fontSize, + const QSize &imageSize, + TextStyle style) { + if (text.isEmpty()) { + return {}; + } + auto processedText = text; + processedText.replace('\n', QChar::LineSeparator); + const auto m = ComputeMetrics(processedText, fontSize, imageSize, style); + return QSize( + m.contentWidth + 2 * m.padding, + m.contentHeight + 2 * m.padding); +} + +void ItemText::paint( + QPainter *p, + const QStyleOptionGraphicsItem *option, + QWidget *w) { + if (!_pixmap.isNull()) { + const auto rect = contentRect(); + const auto pixmapSize = QSizeF( + _pixmap.size() / style::DevicePixelRatio() + ).scaled(rect.size(), Qt::KeepAspectRatio); + const auto resultRect = QRectF( + rect.topLeft(), + pixmapSize + ).translated( + (rect.width() - pixmapSize.width()) / 2., + (rect.height() - pixmapSize.height()) / 2.); + if (flipped()) { + p->save(); + const auto center = resultRect.center(); + p->translate(center); + p->scale(-1, 1); + p->translate(-center); + p->drawPixmap(resultRect.toRect(), _pixmap); + p->restore(); + } else { + p->drawPixmap(resultRect.toRect(), _pixmap); + } + } + ItemBase::paint(p, option, w); +} + +int ItemText::type() const { + return Type; +} + +const QString &ItemText::text() const { + return _text; +} + +void ItemText::setText(const QString &text) { + _text = text; + renderContent(); + update(); +} + +const QColor &ItemText::color() const { + return _color; +} + +void ItemText::setColor(const QColor &color) { + _color = color; + renderContent(); + update(); +} + +float64 ItemText::fontSize() const { + return _fontSize; +} + +float64 ItemText::editScale() const { + const auto natural = computeContentSize( + _text, + _fontSize, + _imageSize, + _textStyle); + if (natural.width() <= 0) { + return 1.; + } + return size() / natural.width(); +} + +TextStyle ItemText::textStyle() const { + return _textStyle; +} + +void ItemText::setTextStyle(TextStyle style) { + _textStyle = style; + renderContent(); + update(); +} + +void ItemText::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { + if (const auto s = static_cast(scene())) { + s->startTextEditing(this); + } +} + +void ItemText::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { + if (scene()) { + scene()->clearSelection(); + setSelected(true); + } + + _contextMenu = base::make_unique_q( + nullptr, + st::mediaviewPopupMenu); + const auto add = [&]( + const QString &text, + TextStyle style, + const style::icon *icon) { + const auto checked = (_textStyle == style); + auto action = _contextMenu->addAction( + text, + [=] { setTextStyle(style); }, + icon); + if (checked) { + action->setChecked(true); + } + }; + add( + tr::lng_photo_editor_text_style_plain(tr::now), + TextStyle::Plain, + &st::mediaMenuIconTextStylePlain); + add( + tr::lng_photo_editor_text_style_framed(tr::now), + TextStyle::Framed, + &st::mediaMenuIconTextStyleFramed); + add( + tr::lng_photo_editor_text_style_semi_transparent(tr::now), + TextStyle::SemiTransparent, + &st::mediaMenuIconTextStyleSemiTransparent); + + _contextMenu->addSeparator(); + + _contextMenu->addAction( + tr::lng_photo_editor_menu_duplicate(tr::now), + [=] { actionDuplicate(); }, + &st::mediaMenuIconCopy); + _contextMenu->addAction( + tr::lng_photo_editor_menu_delete(tr::now), + [=] { actionDelete(); }, + &st::mediaMenuIconDelete); + + _contextMenu->popup(event->screenPos()); +} + +void ItemText::performFlip() { + update(); +} + +std::shared_ptr ItemText::duplicate(ItemBase::Data data) const { + return std::make_shared( + _text, + _color, + _fontSize, + _textStyle, + _imageSize, + std::move(data)); +} + +void ItemText::save(SaveState state) { + ItemBase::save(state); + auto &saved = (state == SaveState::Keep) ? _keepedState : _savedState; + saved = { + .text = _text, + .color = _color, + .fontSize = _fontSize, + .textStyle = _textStyle, + }; +} + +void ItemText::restore(SaveState state) { + if (!hasState(state)) { + return; + } + const auto &saved = (state == SaveState::Keep) ? _keepedState : _savedState; + _text = saved.text; + _color = saved.color; + _fontSize = saved.fontSize; + _textStyle = saved.textStyle; + renderContent(); + ItemBase::restore(state); +} + +} // namespace Editor diff --git a/Telegram/SourceFiles/editor/scene/scene_item_text.h b/Telegram/SourceFiles/editor/scene/scene_item_text.h new file mode 100644 index 0000000000..a1898b2b3e --- /dev/null +++ b/Telegram/SourceFiles/editor/scene/scene_item_text.h @@ -0,0 +1,93 @@ +/* +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/unique_qptr.h" +#include "editor/scene/scene_item_base.h" + +namespace Ui { +class PopupMenu; +} // namespace Ui + +namespace Editor { + +enum class TextStyle : uchar { + Framed, + SemiTransparent, + Plain, +}; + +[[nodiscard]] QColor EffectiveTextColor(const QColor &color, TextStyle style); + +class ItemText : public ItemBase { +public: + enum { Type = ItemBase::Type + 2 }; + + ItemText( + const QString &text, + const QColor &color, + float64 fontSize, + TextStyle style, + const QSize &imageSize, + ItemBase::Data data); + + void paint( + QPainter *p, + const QStyleOptionGraphicsItem *option, + QWidget *widget) override; + int type() const override; + + [[nodiscard]] const QString &text() const; + void setText(const QString &text); + + [[nodiscard]] const QColor &color() const; + void setColor(const QColor &color); + + [[nodiscard]] float64 fontSize() const; + + [[nodiscard]] TextStyle textStyle() const; + void setTextStyle(TextStyle style); + + [[nodiscard]] float64 editScale() const; + + [[nodiscard]] static QSize computeContentSize( + const QString &text, + float64 fontSize, + const QSize &imageSize, + TextStyle style); + + void save(SaveState state) override; + void restore(SaveState state) override; + +protected: + void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override; + void contextMenuEvent(QGraphicsSceneContextMenuEvent *event) override; + void performFlip() override; + std::shared_ptr duplicate(ItemBase::Data data) const override; + +private: + void renderContent(); + + QString _text; + QColor _color; + float64 _fontSize; + TextStyle _textStyle = TextStyle::Plain; + QSize _imageSize; + QPixmap _pixmap; + base::unique_qptr _contextMenu; + + struct SavedText { + QString text; + QColor color; + float64 fontSize = 0.; + TextStyle textStyle = TextStyle::Plain; + }; + SavedText _savedState, _keepedState; +}; + +} // namespace Editor diff --git a/Telegram/SourceFiles/history/history.cpp b/Telegram/SourceFiles/history/history.cpp index bf9abbea69..0f012e7ff8 100644 --- a/Telegram/SourceFiles/history/history.cpp +++ b/Telegram/SourceFiles/history/history.cpp @@ -1456,6 +1456,8 @@ void History::applyServiceChanges( answer.vtext()); if (!poll->answerByOption(parsed.option)) { poll->answers.push_back(std::move(parsed)); + ++poll->version; + owner().notifyPollUpdateDelayed(poll); } }, [](const auto &) {}); } @@ -1470,9 +1472,14 @@ void History::applyServiceChanges( if (const auto poll = media->poll()) { const auto option = del->answer.option; auto &answers = poll->answers; + const auto size = answers.size(); answers.erase( ranges::remove(answers, option, &PollAnswer::option), end(answers)); + if (answers.size() != size) { + ++poll->version; + owner().notifyPollUpdateDelayed(poll); + } } } } @@ -1604,9 +1611,6 @@ void History::newItemAdded(not_null item) { if (const auto sublist = item->savedSublist()) { sublist->applyItemAdded(item); } - if (const auto streamed = _streamedDrafts.get()) { - streamed->applyItemAdded(item); - } if (const auto media = item->media()) { if (const auto gift = media->gift()) { if (const auto unique = gift->unique.get()) { @@ -3973,6 +3977,10 @@ HistoryStreamedDrafts &History::streamedDrafts() { return *_streamedDrafts; } +HistoryStreamedDrafts *History::streamedDraftsIfExists() const { + return _streamedDrafts.get(); +} + HistoryItem *History::joinedMessageInstance() const { return _joinedMessage; } diff --git a/Telegram/SourceFiles/history/history.h b/Telegram/SourceFiles/history/history.h index e8848ecae3..f85d8daa21 100644 --- a/Telegram/SourceFiles/history/history.h +++ b/Telegram/SourceFiles/history/history.h @@ -93,6 +93,7 @@ public: [[nodiscard]] Data::HistoryMessages *maybeMessages(); [[nodiscard]] HistoryStreamedDrafts &streamedDrafts(); + [[nodiscard]] HistoryStreamedDrafts *streamedDraftsIfExists() const; [[nodiscard]] HistoryItem *joinedMessageInstance() const; void checkLocalMessages(); diff --git a/Telegram/SourceFiles/history/history_inner_widget.cpp b/Telegram/SourceFiles/history/history_inner_widget.cpp index 260b0567c4..ea3be71c9c 100644 --- a/Telegram/SourceFiles/history/history_inner_widget.cpp +++ b/Telegram/SourceFiles/history/history_inner_widget.cpp @@ -94,6 +94,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "apiwrap.h" #include "api/api_attached_stickers.h" #include "api/api_suggest_post.h" +#include "api/api_stickers_creator.h" #include "api/api_toggling_media.h" #include "api/api_who_reacted.h" #include "api/api_views.h" @@ -3055,6 +3056,7 @@ void HistoryInner::showContextMenu(QContextMenuEvent *e, bool showFromTouch) { [=] { const auto cur = HistoryView::CurrentVoiceTimecode(msgId); + _widget->replyToMessage({ .messageId = msgId }); _widget->insertTextAtCursor(cur.value_or(*t)); }); } @@ -3263,6 +3265,11 @@ void HistoryInner::showContextMenu(QContextMenuEvent *e, bool showFromTouch) { _menu->addAction(document->isStickerSetInstalled() ? tr::lng_context_pack_info(tr::now) : tr::lng_context_pack_add(tr::now), [=] { showStickerPackInfo(document); }, &st::menuIconStickers); + } else { + Api::AddAddToOwnedSetAction( + Ui::Menu::CreateAddActionCallback(_menu), + _controller->uiShow(), + document); } { const auto isFaved = session->data().stickers().isFaved(document); @@ -3504,6 +3511,7 @@ void HistoryInner::showContextMenu(QContextMenuEvent *e, bool showFromTouch) { &session->data(), pollItemId, pollOptionLink, + _controller, [=] { _widget->replyToMessage({ .messageId = pollItemId, diff --git a/Telegram/SourceFiles/history/history_item.cpp b/Telegram/SourceFiles/history/history_item.cpp index 5b8a908b56..1e1219eb80 100644 --- a/Telegram/SourceFiles/history/history_item.cpp +++ b/Telegram/SourceFiles/history/history_item.cpp @@ -444,6 +444,12 @@ HistoryItem::HistoryItem( _flags |= MessageFlag::Legacy; createComponents(data); setText(UnsupportedMessageText()); + if (!Has()) { + AddComponents(HistoryMessageReplyMarkup::Bit()); + } + _flags |= MessageFlag::HasReplyMarkup; + Get()->updateData( + UnsupportedMessageMarkup()); } else if (checked == MediaCheckResult::Empty) { AddComponents(HistoryServiceData::Bit()); setServiceText({ @@ -2188,6 +2194,9 @@ void HistoryItem::applyEdition(HistoryMessageEdition &&edition) { setText(std::move(updatedText)); addToSharedMediaIndex(); } + if (mediaCheck == MediaCheckResult::Unsupported) { + setReplyMarkup(UnsupportedMessageMarkup()); + } if (!edition.useSameReplies) { if (!edition.replies.isNull) { if (checkRepliesPts(edition.replies)) { @@ -2379,6 +2388,9 @@ void HistoryItem::applySentMessage(const MTPDmessage &data) { : PeerId(); if (!replyToPeer || replyToPeer == history()->peer->id) { if (const auto replyToId = data.vreply_to_msg_id()) { + if (!isService() && !Has()) { + AddComponents(HistoryMessageReply::Bit()); + } setReplyFields( replyToId->v, data.vreply_to_top_id().value_or(replyToId->v), @@ -2440,6 +2452,7 @@ void HistoryItem::updateSentContent( _flags &= ~MessageFlag::HasPostAuthor; _flags |= MessageFlag::Legacy; setText(UnsupportedMessageText()); + setReplyMarkup(UnsupportedMessageMarkup()); } else { if (_flags & MessageFlag::Legacy) { _flags &= ~MessageFlag::Legacy; @@ -2720,7 +2733,8 @@ void HistoryItem::addToMessagesIndex() { } void HistoryItem::incrementReplyToTopCounter() { - if (isRegular() && _history->peer->isMegagroup()) { + if (isRegular() + && (_history->peer->isMegagroup() || _history->peer->forum())) { _history->session().changes().messageUpdated( this, Data::MessageUpdate::Flag::ReplyToTopAdded); @@ -2760,12 +2774,19 @@ QString HistoryItem::notificationHeader() const { return QString(); } +void HistoryItem::markTextAppearingStarted() { + _flags |= MessageFlag::TextAppearingStarted; +} + void HistoryItem::setRealId(MsgId newId) { - Expects(_flags & MessageFlag::BeingSent); + Expects(isSending() || textAppearing()); Expects(IsClientMsgId(id)); const auto oldId = std::exchange(id, newId); _flags &= ~(MessageFlag::BeingSent | MessageFlag::Local); + if (textAppearing()) { + markTextAppearingStarted(); + } if (isBusinessShortcut()) { _date = 0; } @@ -2788,9 +2809,10 @@ void HistoryItem::setRealId(MsgId newId) { _history->owner().requestItemResize(this); _history->owner().requestItemRepaint(this); - if (Has()) { - incrementReplyToTopCounter(); - } + incrementReplyToTopCounter(); + _history->session().changes().messageUpdated( + this, + Data::MessageUpdate::Flag::NewMaybeAdded); if (out() && starsPaid()) { _history->session().credits().load(true); @@ -4128,7 +4150,7 @@ void HistoryItem::detectTextLinks( } } -void HistoryItem::setText(const TextWithEntities &textWithEntities) { +void HistoryItem::setText(TextWithEntities textWithEntities) { auto text = textWithEntities; const auto &settings = AyuSettings::getInstance(); if (settings.filterZalgo()) { @@ -4156,13 +4178,6 @@ void HistoryItem::setTextValue(TextWithEntities text, bool force) { } } -void HistoryItem::setTextStreaming(TextWithEntities text) { - detectTextLinks(text); - _text = std::move(text); - RemoveComponents(HistoryMessageTranslation::Bit()); - history()->owner().requestItemTextRefreshStreaming(this); -} - bool HistoryItem::inHighlightProcess() const { return _flags & MessageFlag::InHighlightProcess; } diff --git a/Telegram/SourceFiles/history/history_item.h b/Telegram/SourceFiles/history/history_item.h index 559df56263..9d8fdc2d05 100644 --- a/Telegram/SourceFiles/history/history_item.h +++ b/Telegram/SourceFiles/history/history_item.h @@ -345,9 +345,12 @@ public: [[nodiscard]] bool canBeSummarized() const { return _flags & MessageFlag::CanBeSummarized; } - [[nodiscard]] bool isTextAppearing() const { + [[nodiscard]] bool textAppearing() const { return _flags & MessageFlag::TextAppearing; } + [[nodiscard]] bool textAppearingStarted() const { + return _flags & MessageFlag::TextAppearingStarted; + } [[nodiscard]] bool hasRealFromId() const; [[nodiscard]] bool isPostHidingAuthor() const; [[nodiscard]] bool isPostShowingAuthor() const; @@ -449,6 +452,7 @@ public: void applyTTL(TimeId destroyAt); void setAyuHint(const QString &hint); void setRealId(MsgId newId); + void markTextAppearingStarted(); void incrementReplyToTopCounter(); void applyEffectWatchedOnUnreadKnown(); @@ -530,8 +534,7 @@ public: return _media.get(); } [[nodiscard]] bool computeDropForwardedInfo() const; - void setText(const TextWithEntities &textWithEntities); - void setTextStreaming(TextWithEntities text); + void setText(TextWithEntities textWithEntities); [[nodiscard]] MsgId replyToId() const; [[nodiscard]] FullMsgId replyToFullId() const; diff --git a/Telegram/SourceFiles/history/history_item_components.cpp b/Telegram/SourceFiles/history/history_item_components.cpp index a085397af8..7450a9f5ec 100644 --- a/Telegram/SourceFiles/history/history_item_components.cpp +++ b/Telegram/SourceFiles/history/history_item_components.cpp @@ -157,6 +157,7 @@ void HistoryMessageVia::resize(int32 availw) const { tr::now, lt_inline_bot, '@' + bot->username()); + maxWidth = st::msgServiceNameFont->width(text); if (availw < maxWidth) { text = st::msgServiceNameFont->elided(text, availw); width = st::msgServiceNameFont->width(text); @@ -689,6 +690,11 @@ ReplyMarkupClickHandler::ReplyMarkupClickHandler( , _column(column) { } +QString ReplyMarkupClickHandler::dragText() const { + const auto button = getUrlButton(); + return button ? QString::fromUtf8(button->data) : QString(); +} + // Copy to clipboard support. QString ReplyMarkupClickHandler::copyToClipboardText() const { const auto button = getUrlButton(); diff --git a/Telegram/SourceFiles/history/history_item_components.h b/Telegram/SourceFiles/history/history_item_components.h index 185314bb7c..39fd5058e1 100644 --- a/Telegram/SourceFiles/history/history_item_components.h +++ b/Telegram/SourceFiles/history/history_item_components.h @@ -423,6 +423,8 @@ public: _fullDisplayed = full; } + QString dragText() const override; + // Copy to clipboard support. QString copyToClipboardText() const override; QString copyToClipboardContextItemText() const override; diff --git a/Telegram/SourceFiles/history/history_item_helpers.cpp b/Telegram/SourceFiles/history/history_item_helpers.cpp index f7929d698f..132b12177f 100644 --- a/Telegram/SourceFiles/history/history_item_helpers.cpp +++ b/Telegram/SourceFiles/history/history_item_helpers.cpp @@ -658,20 +658,27 @@ TextWithEntities DropDisallowedCustomEmoji( if (to->session().premium() || to->isSelf()) { return text; } + const auto isLocalIconEmoji = [](const EntityInText &entity) { + return entity.data().startsWith(u"icon-emoji-"_q); + }; const auto channel = to->asMegagroup(); const auto allowSetId = channel ? channel->mgInfo->emojiSet.id : 0; if (!allowSetId) { + const auto predicate = [&](const EntityInText &entity) { + return (entity.type() == EntityType::CustomEmoji) + && !isLocalIconEmoji(entity); + }; text.entities.erase( - ranges::remove( - text.entities, - EntityType::CustomEmoji, - &EntityInText::type), + ranges::remove_if(text.entities, predicate), text.entities.end()); } else { const auto predicate = [&](const EntityInText &entity) { if (entity.type() != EntityType::CustomEmoji) { return false; } + if (isLocalIconEmoji(entity)) { + return false; + } if (const auto id = Data::ParseCustomEmojiData(entity.data())) { const auto document = to->owner().document(id); if (const auto sticker = document->sticker()) { @@ -1335,6 +1342,20 @@ void CheckPollVoteNotificationSchedule( return result; } +HistoryMessageMarkupData UnsupportedMessageMarkup() { + using Button = HistoryMessageMarkupButton; + auto markup = HistoryMessageMarkupData(); + markup.flags = ReplyMarkupFlag::Inline; + auto row = std::vector