diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 7875e86e2e..e77d098baa 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -709,6 +709,8 @@ PRIVATE dialogs/dialogs_search_from_controllers.h dialogs/dialogs_search_tags.cpp dialogs/dialogs_search_tags.h + dialogs/dialogs_search_posts.cpp + dialogs/dialogs_search_posts.h dialogs/dialogs_top_bar_suggestion.cpp dialogs/dialogs_top_bar_suggestion.h dialogs/dialogs_widget.cpp diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 2290231cee..b363967f1a 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -6745,6 +6745,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_recent_chats" = "Chats"; "lng_recent_channels" = "Channels"; "lng_recent_apps" = "Apps"; +"lng_recent_posts" = "Posts"; "lng_all_photos" = "Photos"; "lng_all_videos" = "Videos"; "lng_all_downloads" = "Downloads"; @@ -6762,6 +6763,22 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_bot_apps_popular" = "Grossing apps"; "lng_bot_apps_which" = "Which apps are included here? {link}"; "lng_bot_apps_which_link" = "Learn >"; +"lng_posts_title" = "Global Search"; +"lng_posts_start" = "Type a keyword to search all posts from public channels."; +"lng_posts_subscribe" = "Subscribe to Premium"; +"lng_posts_need_subscribe" = "Global search is a Premium feature."; +"lng_posts_search_button" = "Search {query}"; +"lng_posts_remaining#one" = "{count} free search remaining today"; +"lng_posts_remaining#other" = "{count} free searches remaining today"; +"lng_posts_subtitle_empty" = "Telegram News"; +"lng_posts_subtitle" = "Public posts"; +"lng_posts_limit_reached" = "Limit Reached"; +"lng_posts_limit_about#one" = "You can make up to {count} search query per day."; +"lng_posts_limit_about#other" = "You can make up to {count} search queries per day."; +"lng_posts_limit_search_paid" = "Search for {cost}"; +"lng_posts_limit_unlocks" = "free search unlocks in {duration}"; +"lng_posts_paid_spent#one" = "**{count} Star** spent on extra search."; +"lng_posts_paid_spent#other" = "**{count} Stars** spent on extra search."; "lng_popular_apps_info_title" = "Top Mini Apps"; "lng_popular_apps_info_text" = "This catalogue ranks mini apps based on their daily revenue, measured in Stars. To be listed, developers must set their main mini apps in {bot} (as described {link}), have over **1,000** daily users, and earn a daily revenue above **1,000** Stars, based on the weekly average."; diff --git a/Telegram/SourceFiles/boxes/send_credits_box.cpp b/Telegram/SourceFiles/boxes/send_credits_box.cpp index c35951da35..8f355b1aa6 100644 --- a/Telegram/SourceFiles/boxes/send_credits_box.cpp +++ b/Telegram/SourceFiles/boxes/send_credits_box.cpp @@ -567,49 +567,6 @@ not_null SetButtonMarkedLabel( }), st, textFg); } -void SetButtonTwoLabels( - not_null button, - rpl::producer title, - rpl::producer subtitle, - const style::FlatLabel &st, - const style::FlatLabel &subst, - const style::color *textFg) { - const auto buttonTitle = Ui::CreateChild( - button, - std::move(title), - st); - const auto buttonSubtitle = Ui::CreateChild( - button, - std::move(subtitle), - subst); - buttonSubtitle->setOpacity(0.6); - if (textFg) { - buttonTitle->setTextColorOverride((*textFg)->c); - buttonSubtitle->setTextColorOverride((*textFg)->c); - style::PaletteChanged() | rpl::start_with_next([=] { - buttonTitle->setTextColorOverride((*textFg)->c); - buttonSubtitle->setTextColorOverride((*textFg)->c); - }, buttonTitle->lifetime()); - } - rpl::combine( - button->sizeValue(), - buttonTitle->sizeValue(), - buttonSubtitle->sizeValue() - ) | rpl::start_with_next([=](QSize outer, QSize title, QSize subtitle) { - const auto two = title.height() + subtitle.height(); - const auto titleTop = (outer.height() - two) / 2; - const auto subtitleTop = titleTop + title.height(); - buttonTitle->moveToLeft( - (outer.width() - title.width()) / 2, - titleTop); - buttonSubtitle->moveToLeft( - (outer.width() - subtitle.width()) / 2, - subtitleTop); - }, buttonTitle->lifetime()); - buttonTitle->setAttribute(Qt::WA_TransparentForMouseEvents); - buttonSubtitle->setAttribute(Qt::WA_TransparentForMouseEvents); -} - void SendStarsForm( not_null session, std::shared_ptr data, diff --git a/Telegram/SourceFiles/boxes/send_credits_box.h b/Telegram/SourceFiles/boxes/send_credits_box.h index f8227846b9..6dcaef1f8e 100644 --- a/Telegram/SourceFiles/boxes/send_credits_box.h +++ b/Telegram/SourceFiles/boxes/send_credits_box.h @@ -52,14 +52,6 @@ not_null SetButtonMarkedLabel( const style::FlatLabel &st, const style::color *textFg = nullptr); -void SetButtonTwoLabels( - not_null button, - rpl::producer title, - rpl::producer subtitle, - const style::FlatLabel &st, - const style::FlatLabel &subst, - const style::color *textFg = nullptr); - void SendStarsForm( not_null session, std::shared_ptr data, diff --git a/Telegram/SourceFiles/dialogs/dialogs.style b/Telegram/SourceFiles/dialogs/dialogs.style index 40af906830..7b81b4c577 100644 --- a/Telegram/SourceFiles/dialogs/dialogs.style +++ b/Telegram/SourceFiles/dialogs/dialogs.style @@ -832,3 +832,34 @@ dialogsTopBarSuggestionTitleStyle: TextStyle(defaultTextStyle) { dialogsTopBarSuggestionAboutStyle: TextStyle(defaultTextStyle) { font: font(11px); } + +postsSearchIntroTitle: FlatLabel(defaultFlatLabel) { + textFg: windowBoldFg; + minWidth: 64px; + style: TextStyle(semiboldTextStyle) { + font: font(semibold 16px); + } + align: align(top); +} +postsSearchIntroTitleMargin: margins(20px, 0px, 20px, 12px); +postsSearchIntroSubtitle: FlatLabel(defaultFlatLabel) { + textFg: windowSubTextFg; + minWidth: 64px; + align: align(top); +} +postsSearchIntroSubtitleMargin: margins(20px, 4px, 20px, 12px); +postsSearchIntroButton: RoundButton(defaultActiveButton) { + width: 200px; + height: 42px; + textTop: 12px; + style: semiboldTextStyle; +} +postsSearchIntroFooter: FlatLabel(defaultFlatLabel) { + textFg: windowSubTextFg; + minWidth: 64px; + align: align(top); + style: TextStyle(defaultTextStyle) { + font: font(12px); + } +} +postsSearchIntroFooterMargin: margins(20px, 12px, 20px, 0px); diff --git a/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp b/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp index 4ec8f4455c..4a7f9f57b4 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp +++ b/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp @@ -1276,6 +1276,10 @@ void InnerWidget::paintEvent(QPaintEvent *e) { if (!_searchResults.empty()) { const auto text = showUnreadInSearchResults ? u"Search results"_q + : (_searchState.tab == ChatSearchTab::PublicPosts && !_searchIn) + ? (_searchState.query.isEmpty() + ? tr::lng_posts_subtitle_empty(tr::now) + : tr::lng_posts_subtitle(tr::now)) : tr::lng_search_found_results( tr::now, lt_count, @@ -3089,7 +3093,8 @@ void InnerWidget::fillArchiveSearchMenu(not_null menu) { || (_searchState.tab == ChatSearchTab::PublicPosts); if (!folder || !folder->chatsList()->fullSize().current() - || (!globalSearch && _searchState.inChat)) { + || (!globalSearch && _searchState.inChat) + || (_searchState.tab == ChatSearchTab::PublicPosts)) { return; } const auto skip = session().settings().skipArchiveInSearch(); @@ -3468,7 +3473,8 @@ void InnerWidget::applySearchState(SearchState state) { _filter = newFilter; if (_filter.isEmpty() && !_searchState.fromPeer - && _searchState.tags.empty()) { + && _searchState.tags.empty() + && _searchState.tab != ChatSearchTab::PublicPosts) { clearFilter(); } else { setState(WidgetState::Filtered); diff --git a/Telegram/SourceFiles/dialogs/dialogs_search_posts.cpp b/Telegram/SourceFiles/dialogs/dialogs_search_posts.cpp new file mode 100644 index 0000000000..e1fcbe87b1 --- /dev/null +++ b/Telegram/SourceFiles/dialogs/dialogs_search_posts.cpp @@ -0,0 +1,283 @@ +/* +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 "dialogs/dialogs_search_posts.h" + +#include "apiwrap.h" +#include "base/unixtime.h" +#include "data/data_session.h" +#include "data/data_peer_values.h" +#include "history/history.h" +#include "main/main_session.h" + +namespace Dialogs { +namespace { + +constexpr auto kQueryDelay = crl::time(500); +constexpr auto kPerPage = 50; + +} // namespace + +PostsSearch::PostsSearch(not_null session) +: _session(session) +, _api(&_session->api().instance()) +, _timer([=] { applyQuery(); }) +, _recheckTimer([=] { recheck(); }) { + Data::AmPremiumValue(_session) | rpl::start_with_next([=] { + maybePushPremiumUpdate(); + }, _lifetime); +} + +rpl::producer PostsSearch::stateUpdates() const { + return _stateUpdates.events(); +} + +void PostsSearch::requestMore() { + if (_query) { + requestSearch(*_query); + } +} + +void PostsSearch::setQuery(const QString &query) { + if (_query == query) { + return; + } + _query = query; + const auto i = _entries.find(query); + if (i != end(_entries)) { + pushStateUpdate(i->second); + } else if (query.isEmpty()) { + applyQuery(); + } else { + _timer.callOnce(kQueryDelay); + } +} + +void PostsSearch::setAllowedStars(int stars) { + if (_query) { + _entries[*_query].allowedStars = stars; + requestSearch(*_query); + } +} + +void PostsSearch::pushStateUpdate(const Entry &entry) { + if (!entry.pages.empty() || entry.loaded) { + _stateUpdates.fire(PostsSearchState{ + .first = (entry.pages.empty() + ? std::vector>() + : entry.pages.front()), + .totalCount = entry.totalCount, + }); + } else if (entry.checkId || entry.searchId) { + _stateUpdates.fire(PostsSearchState{ + .loading = true, + }); + } else { + Assert(_floodState.has_value()); + auto copy = _floodState; + copy->query = *_query; + copy->needsPremium = !_session->premium(); + _stateUpdates.fire(PostsSearchState{ + .intro = std::move(copy), + }); + } +} + +void PostsSearch::maybePushPremiumUpdate() { + if (!_floodState || !_query) { + return; + } + auto &entry = _entries[*_query]; + if (!entry.pages.empty() + || entry.loaded + || entry.checkId + || entry.searchId) { + return; + } + pushStateUpdate(entry); +} + +void PostsSearch::applyQuery() { + Expects(_query.has_value()); + + _timer.cancel(); + if (_query->isEmpty()) { + requestSearch(QString()); + } else { + requestState(*_query); + } +} + +void PostsSearch::requestSearch(const QString &query) { + auto &entry = _entries[query]; + if (entry.searchId || entry.loaded) { + return; + } + + using Flag = MTPchannels_SearchPosts::Flag; + entry.searchId = _api.request(MTPchannels_SearchPosts( + MTP_flags(Flag::f_query + | (entry.allowedStars ? Flag::f_allow_paid_stars : Flag())), + MTP_string(), // hashtag + MTP_string(query), + MTP_int(entry.offsetRate), + (entry.offsetPeer ? entry.offsetPeer->input : MTP_inputPeerEmpty()), + MTP_int(entry.offsetId), + MTP_int(kPerPage), + MTP_long(entry.allowedStars) + )).done([=](const MTPmessages_Messages &result) { + auto &entry = _entries[query]; + entry.searchId = 0; + + const auto initial = !entry.offsetId; + const auto owner = &_session->data(); + const auto processList = [&](const MTPVector &messages) { + auto result = std::vector>(); + for (const auto &message : messages.v) { + const auto msgId = IdFromMessage(message); + const auto peerId = PeerFromMessage(message); + const auto lastDate = DateFromMessage(message); + if (const auto peer = owner->peerLoaded(peerId)) { + if (lastDate) { + const auto item = owner->addNewMessage( + message, + MessageFlags(), + NewMessageType::Existing); + result.push_back(item); + } + entry.offsetPeer = peer; + } else { + LOG(("API Error: a search results with not loaded peer %1" + ).arg(peerId.value)); + } + entry.offsetId = msgId; + } + return result; + }; + auto totalCount = 0; + auto messages = result.match([&](const MTPDmessages_messages &data) { + owner->processUsers(data.vusers()); + owner->processChats(data.vchats()); + entry.loaded = true; + auto list = processList(data.vmessages()); + totalCount = list.size(); + return list; + }, [&](const MTPDmessages_messagesSlice &data) { + owner->processUsers(data.vusers()); + owner->processChats(data.vchats()); + auto list = processList(data.vmessages()); + const auto nextRate = data.vnext_rate(); + const auto rateUpdated = nextRate + && (nextRate->v != entry.offsetRate); + const auto finished = list.empty(); + if (rateUpdated) { + entry.offsetRate = nextRate->v; + } + if (finished) { + entry.loaded = true; + } + totalCount = data.vcount().v; + if (const auto flood = data.vsearch_flood()) { + setFloodStateFrom(flood->data()); + } + return list; + }, [&](const MTPDmessages_channelMessages &data) { + LOG(("API Error: " + "received messages.channelMessages when no channel " + "was passed! (PostsSearch::performSearch)")); + owner->processUsers(data.vusers()); + owner->processChats(data.vchats()); + auto list = processList(data.vmessages()); + if (list.empty()) { + entry.loaded = true; + } + totalCount = data.vcount().v; + return list; + }, [&](const MTPDmessages_messagesNotModified &) { + LOG(("API Error: received messages.messagesNotModified! " + "(PostsSearch::performSearch)")); + entry.loaded = true; + return std::vector>(); + }); + if (initial) { + entry.pages.clear(); + } + entry.pages.push_back(std::move(messages)); + const auto count = int(entry.pages.size()); + const auto full = entry.loaded ? count : std::max(count, totalCount); + entry.totalCount = full; + if (initial && _query == query) { + pushStateUpdate(entry); + } + }).fail([=](const MTP::Error &error) { + auto &entry = _entries[query]; + entry.searchId = 0; + + const auto initial = !entry.offsetId; + const auto &type = error.type(); + if (initial && type.startsWith(u"FLOOD_WAIT_"_q)) { + requestState(query); + } else { + entry.loaded = true; + } + }).handleFloodErrors().send(); +} + +void PostsSearch::setFloodStateFrom(const MTPDsearchPostsFlood &data) { + _recheckTimer.cancel(); + const auto left = data.vremains().v; + const auto next = data.vwait_till().value_or_empty(); + if (!left && next > 0) { + const auto now = base::unixtime::now(); + const auto delay = std::clamp(next - now, 1, 86401); + _recheckTimer.callOnce(delay * crl::time(1000)); + } + _floodState = PostsSearchIntroState{ + .freeSearchesPerDay = data.vtotal_daily().v, + .freeSearchesLeft = left, + .nextFreeSearchTime = next, + .starsPerPaidSearch = uint32(data.vstars_amount().v), + }; +} + +void PostsSearch::recheck() { + requestState(*_query, true); +} + +void PostsSearch::requestState(const QString &query, bool force) { + auto &entry = _entries[query]; + if (force) { + _api.request(base::take(entry.checkId)).cancel(); + } else if (entry.checkId || entry.loaded) { + return; + } + + using Flag = MTPchannels_CheckSearchPostsFlood::Flag; + entry.checkId = _api.request(MTPchannels_CheckSearchPostsFlood( + MTP_flags(Flag::f_query), + MTP_string(query) + )).done([=](const MTPSearchPostsFlood &result) { + auto &entry = _entries[query]; + entry.checkId = 0; + + const auto &data = result.data(); + setFloodStateFrom(data); + if (data.is_query_is_free()) { + if (!entry.loaded) { + requestSearch(query); + } + } else if (_query == query) { + pushStateUpdate(entry); + } + }).fail([=](const MTP::Error &error) { + auto &entry = _entries[query]; + entry.checkId = 0; + entry.loaded = true; + }).handleFloodErrors().send(); +} + +} // namespace Dialogs diff --git a/Telegram/SourceFiles/dialogs/dialogs_search_posts.h b/Telegram/SourceFiles/dialogs/dialogs_search_posts.h new file mode 100644 index 0000000000..630c461ab2 --- /dev/null +++ b/Telegram/SourceFiles/dialogs/dialogs_search_posts.h @@ -0,0 +1,75 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "base/timer.h" +#include "dialogs/ui/posts_search_intro.h" +#include "mtproto/sender.h" + +namespace Main { +class Session; +} // namespace Main + +namespace Dialogs { + +struct PostsSearchState { + std::optional intro; + std::vector> first; + int totalCount = 0; + bool loading = false; +}; + +class PostsSearch final { +public: + explicit PostsSearch(not_null session); + + [[nodiscard]] rpl::producer stateUpdates() const; + + void setQuery(const QString &query); + void setAllowedStars(int stars); + void requestMore(); + +private: + struct Entry { + std::vector>> pages; + int totalCount = 0; + mtpRequestId searchId = 0; + mtpRequestId checkId = 0; + PeerData *offsetPeer = nullptr; + MsgId offsetId = 0; + int offsetRate = 0; + int allowedStars = 0; + bool loaded = false; + }; + + void recheck(); + void applyQuery(); + void requestSearch(const QString &query); + void requestState(const QString &query, bool force = false); + void setFloodStateFrom(const MTPDsearchPostsFlood &data); + void pushStateUpdate(const Entry &entry); + void maybePushPremiumUpdate(); + + const not_null _session; + + MTP::Sender _api; + + base::Timer _timer; + base::Timer _recheckTimer; + base::flat_map _entries; + std::optional _query; + + std::optional _floodState; + + rpl::event_stream _stateUpdates; + + rpl::lifetime _lifetime; + +}; + +} // namespace Dialogs diff --git a/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.cpp b/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.cpp index a66fe7488b..5786dc9054 100644 --- a/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.cpp +++ b/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.cpp @@ -24,6 +24,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_session.h" #include "data/data_user.h" #include "dialogs/ui/chat_search_empty.h" +#include "dialogs/ui/chat_search_in.h" +#include "dialogs/ui/posts_search_intro.h" +#include "dialogs/dialogs_inner_widget.h" +#include "dialogs/dialogs_search_posts.h" #include "history/history.h" #include "info/downloads/info_downloads_widget.h" #include "info/media/info_media_widget.h" @@ -34,6 +38,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "lang/lang_keys.h" #include "main/main_session.h" #include "settings/settings_common.h" +#include "settings/settings_premium.h" #include "storage/storage_shared_media.h" #include "ui/boxes/confirm_box.h" #include "ui/controls/swipe_handler.h" @@ -1331,6 +1336,8 @@ Suggestions::Suggestions( , _appsScroll(std::make_unique(this)) , _appsContent( _appsScroll->setOwnedWidget(object_ptr(this))) +, _postsScroll(std::make_unique(this)) +, _postsWrap(_postsScroll->setOwnedWidget(object_ptr(this))) , _recentApps(setupRecentApps()) , _popularApps(setupPopularApps()) , _searchQueryTimer([=] { applySearchQuery(); }) { @@ -1397,6 +1404,7 @@ void Suggestions::setupTabs() { { Key{ Tab::Chats }, tr::lng_recent_chats(tr::now) }, { Key{ Tab::Channels }, tr::lng_recent_channels(tr::now) }, { Key{ Tab::Apps }, tr::lng_recent_apps(tr::now) }, + { Key{ Tab::Posts }, tr::lng_recent_posts(tr::now) }, { Key{ Tab::Media, MediaType::Photo }, tr::lng_all_photos(tr::now) }, { Key{ Tab::Media, MediaType::Video }, tr::lng_all_videos(tr::now) }, { Key{ Tab::Downloads }, tr::lng_all_downloads(tr::now) }, @@ -1478,7 +1486,7 @@ void Suggestions::setupChats() { Ui::Text::FixAmpersandInAction), .removeAllConfirm = tr::lng_recent_hide_sure(tr::now), .removeAll = removeAll, - }); + }); }, _topPeers->lifetime()); _topPeers->scrollToRequests( @@ -1496,8 +1504,8 @@ void Suggestions::setupChats() { } void Suggestions::handlePressForChatPreview( - PeerId id, - Fn callback) { + PeerId id, + Fn callback) { callback = crl::guard(this, callback); const auto row = RowDescriptor( _controller->session().data().history(id), @@ -1812,7 +1820,10 @@ bool Suggestions::consumeSearchQuery(const QString &query) { const auto key = _key.current(); const auto tab = key.tab; const auto type = (key.tab == Tab::Media) ? key.mediaType : Type::kCount; - if (tab != Tab::Downloads + if (tab == Tab::Posts) { + setPostsSearchQuery(query); + return !query.isEmpty(); + } else if (tab != Tab::Downloads && type != Type::File && type != Type::Link && type != Type::MusicFile) { @@ -1831,6 +1842,111 @@ bool Suggestions::consumeSearchQuery(const QString &query) { return true; } +void Suggestions::setupPostsSearch() { + _postsSearch = std::make_unique(&_controller->session()); + + _postsSearch->stateUpdates( + ) | rpl::start_with_next([=](const PostsSearchState &state) { + if (state.intro) { + if (!_postsSearchIntro) { + setupPostsIntro(*state.intro); + } else { + _postsSearchIntro->update(*state.intro); + } + return; + } else if (!_postsContent) { + setupPostsResults(); + } + + _postsContent->applySearchState(SearchState{ + .tab = ChatSearchTab::PublicPosts, + .query = _searchQuery, + }); + if (state.loading) { + _postsContent->searchRequested(true); + } else { + _postsContent->searchReceived( + state.first, + nullptr, + { .posts = true, .start = true }, + state.totalCount); + } + const auto top = _postsScroll->scrollTop(); + const auto height = _postsScroll->height(); + _postsContent->setVisibleTopBottom(top, top + height); + }, _postsWrap->lifetime()); +} + +void Suggestions::setPostsSearchQuery(const QString &query) { + if (!_postsSearch) { + setupPostsSearch(); + } + _searchQueryTimer.cancel(); + _postsSearch->setQuery(query); +} + +void Suggestions::setupPostsResults() { + Expects(!_postsContent); + + delete base::take(_postsSearchIntro); + _postsContent = Ui::CreateChild( + _postsWrap.get(), + _controller, + rpl::single(InnerWidget::ChildListShown())); + + _postsContent->applySearchState(SearchState{ + .tab = ChatSearchTab::PublicPosts, + .query = _searchQuery, + }); + _postsContent->searchRequested(true); + + _postsContent->chosenRow( + ) | rpl::start_with_next([=](const ChosenRow &row) { + const auto history = row.key.history(); + if (!history) { + return; + } + _persist = true; + const auto showAtMsgId = row.message.fullId.msg; + auto params = Window::SectionShow( + Window::SectionShow::Way::ClearStack); + params.highlight = Window::SearchHighlightId(_searchQuery); + if (row.newWindow) { + _controller->showInNewWindow(history->peer, showAtMsgId); + } else { + _controller->showThread(history, showAtMsgId, params); + } + }, _postsContent->lifetime()); + + _postsContent->heightValue() | rpl::start_with_next([=](int height) { + _postsWrap->resize(_postsWrap->width(), height); + }, _postsContent->lifetime()); + _postsContent->setNarrowRatio(0.); + _postsContent->show(); + updateControlsGeometry(); +} + +void Suggestions::setupPostsIntro(const PostsSearchIntroState &intro) { + Expects(!_postsSearchIntro); + + delete base::take(_postsContent); + _postsSearchIntro = Ui::CreateChild(_postsWrap, intro); + + _postsSearchIntro->searchWithStars( + ) | rpl::start_with_next([=](int stars) { + if (!_controller->session().premium()) { + Settings::ShowPremium( + _controller, + u"posts_search"_q); + } else { + _postsSearch->setAllowedStars(stars); + } + }, _postsSearchIntro->lifetime()); + + _postsSearchIntro->show(); + updateControlsGeometry(); +} + void Suggestions::applySearchQuery() { const auto key = _key.current(); const auto controller = _mediaLists[key].wrap->controller(); @@ -1926,7 +2042,10 @@ void Suggestions::switchTab(Key key) { } void Suggestions::ensureContent(Key key) { - if (key.tab != Tab::Downloads && key.tab != Tab::Media) { + if (key.tab == Tab::Posts) { + setPostsSearchQuery(QString()); + return; + } else if (key.tab != Tab::Downloads && key.tab != Tab::Media) { return; } auto &list = _mediaLists[key]; @@ -1958,6 +2077,7 @@ void Suggestions::startSlideAnimation(Key was, Key now) { case Tab::Chats: return _chatsScroll.get(); case Tab::Channels: return _channelsScroll.get(); case Tab::Apps: return _appsScroll.get(); + case Tab::Posts: return _postsScroll.get(); } return _mediaLists[key].wrap; }; @@ -2009,6 +2129,7 @@ void Suggestions::startShownAnimation(bool shown, Fn finish) { _chatsScroll->hide(); _channelsScroll->hide(); _appsScroll->hide(); + _postsScroll->hide(); for (const auto &[key, list] : _mediaLists) { list.wrap->hide(); } @@ -2028,6 +2149,7 @@ void Suggestions::finishShow() { _chatsScroll->setVisible(key == Key{ Tab::Chats }); _channelsScroll->setVisible(key == Key{ Tab::Channels }); _appsScroll->setVisible(key == Key{ Tab::Apps }); + _postsScroll->setVisible(key == Key{ Tab::Posts }); for (const auto &[mediaKey, list] : _mediaLists) { list.wrap->setVisible(key == mediaKey); if (key == mediaKey) { @@ -2042,6 +2164,8 @@ void Suggestions::finishShow() { reinstallSwipe(_channelsScroll.get()); } else if (key == Key{ Tab::Apps }) { reinstallSwipe(_appsScroll.get()); + } else if (key == Key{ Tab::Posts }) { + reinstallSwipe(_postsScroll.get()); } } @@ -2055,6 +2179,7 @@ std::vector Suggestions::TabKeysFor( { Tab::Chats }, { Tab::Channels }, { Tab::Apps }, + { Tab::Posts }, { Tab::Media, MediaType::Photo }, { Tab::Media, MediaType::Video }, { Tab::Downloads }, @@ -2119,6 +2244,16 @@ void Suggestions::updateControlsGeometry() { _appsScroll->setGeometry(content); _appsContent->resizeToWidth(w); + _postsScroll->setGeometry(content); + _postsWrap->resizeToWidth(w); + if (_postsSearchIntro) { + _postsSearchIntro->setGeometry(0, 0, w, height() - tabs); + } else if (_postsContent) { + _postsContent->resizeToWidth(w); + _postsContent->setMinimumHeight(height() - tabs); + _postsContent->refresh(); + } + const auto expanding = false; for (const auto &[key, list] : _mediaLists) { const auto full = !list.wrap->scrollBottomSkip(); diff --git a/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.h b/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.h index f269085f13..d536f3a9cd 100644 --- a/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.h +++ b/Telegram/SourceFiles/dialogs/ui/dialogs_suggestions.h @@ -32,10 +32,11 @@ namespace Storage { enum class SharedMediaType : signed char; } // namespace Storage -namespace Ui { -namespace Controls { +namespace Ui::Controls { struct SwipeHandlerArgs; -} // namespace Controls +} // namespace Ui::Controls + +namespace Ui { class BoxContent; class ScrollArea; class ElasticScroll; @@ -51,6 +52,10 @@ class SessionController; namespace Dialogs { +class InnerWidget; +class PostsSearch; +class PostsSearchIntro; +struct PostsSearchIntroState; enum class SearchEmptyIcon; struct RecentPeersList { @@ -118,6 +123,7 @@ private: Chats, Channels, Apps, + Posts, Media, Downloads, }; @@ -209,6 +215,11 @@ private: void updateControlsGeometry(); void applySearchQuery(); + void setupPostsSearch(); + void setPostsSearchQuery(const QString &query); + void setupPostsResults(); + void setupPostsIntro(const PostsSearchIntroState &intro); + const not_null _controller; const std::unique_ptr _tabsScroll; @@ -240,6 +251,12 @@ private: const std::unique_ptr _appsScroll; const not_null _appsContent; + std::unique_ptr _postsSearch; + const std::unique_ptr _postsScroll; + const not_null _postsWrap; + PostsSearchIntro *_postsSearchIntro = nullptr; + InnerWidget *_postsContent = nullptr; + rpl::producer<> _recentAppsRefreshed; Fn)> _recentAppsShows; const std::unique_ptr _recentApps; diff --git a/Telegram/SourceFiles/dialogs/ui/posts_search_intro.cpp b/Telegram/SourceFiles/dialogs/ui/posts_search_intro.cpp new file mode 100644 index 0000000000..b0fe7cbd5b --- /dev/null +++ b/Telegram/SourceFiles/dialogs/ui/posts_search_intro.cpp @@ -0,0 +1,174 @@ +/* +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 "dialogs/ui/posts_search_intro.h" + +#include "base/timer_rpl.h" +#include "base/unixtime.h" +#include "lang/lang_keys.h" +#include "ui/controls/button_two_labels.h" +#include "ui/text/text_utilities.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/labels.h" +#include "ui/wrap/vertical_layout.h" +#include "styles/style_credits.h" +#include "styles/style_dialogs.h" + +namespace Dialogs { +namespace { + +[[nodiscard]] rpl::producer FormatCountdownTill(TimeId when) { + return rpl::single(rpl::empty) | rpl::then( + base::timer_each(1000) + ) | rpl::map([=] { + const auto now = base::unixtime::now(); + const auto delta = std::max(when - now, 0); + const auto hours = delta / 3600; + const auto minutes = (delta % 3600) / 60; + const auto seconds = delta % 60; + constexpr auto kZero = QChar('0'); + return (hours > 0) + ? u"%1:%2:%3"_q + .arg(hours) + .arg(minutes, 2, 10, kZero) + .arg(seconds, 2, 10, kZero) + : u"%1:%2"_q + .arg(minutes) + .arg(seconds, 2, 10, kZero); + }); +} + +} // namespace + +PostsSearchIntro::PostsSearchIntro( + not_null parent, + PostsSearchIntroState state) +: RpWidget(parent) +, _state(std::move(state)) +, _content(std::make_unique(this)) { + setup(); +} + +PostsSearchIntro::~PostsSearchIntro() = default; + +void PostsSearchIntro::update(PostsSearchIntroState state) { + _state = std::move(state); +} + +rpl::producer PostsSearchIntro::searchWithStars() const { + return _button->clicks() | rpl::map([=] { + const auto &now = _state.current(); + return (now.needsPremium || now.freeSearchesLeft) + ? 0 + : int(now.starsPerPaidSearch); + }); +} + +void PostsSearchIntro::setup() { + auto title = _state.value( + ) | rpl::map([](const PostsSearchIntroState &state) { + return (state.needsPremium || state.freeSearchesLeft > 0) + ? tr::lng_posts_title() + : tr::lng_posts_limit_reached(); + }) | rpl::flatten_latest(); + + auto subtitle = _state.value( + ) | rpl::map([](const PostsSearchIntroState &state) { + return (state.needsPremium || state.freeSearchesLeft > 0) + ? tr::lng_posts_start() + : tr::lng_posts_limit_about( + lt_count, + rpl::single(state.freeSearchesPerDay * 1.)); + }) | rpl::flatten_latest(); + + auto footer = _state.value( + ) | rpl::map([](const PostsSearchIntroState &state) { + if (state.needsPremium) { + return tr::lng_posts_need_subscribe(); + } else if (state.freeSearchesLeft > 0) { + return tr::lng_posts_remaining( + lt_count, + rpl::single(state.freeSearchesLeft * 1.)); + } else { + return rpl::single(QString()); + } + }) | rpl::flatten_latest(); + + _title = _content->add( + object_ptr( + _content.get(), + std::move(title), + st::postsSearchIntroTitle), + st::postsSearchIntroTitleMargin); + _title->setTryMakeSimilarLines(true); + _subtitle = _content->add( + object_ptr( + _content.get(), + std::move(subtitle), + st::postsSearchIntroSubtitle), + st::postsSearchIntroSubtitleMargin); + _subtitle->setTryMakeSimilarLines(true); + _button = _content->add( + object_ptr>( + _content.get(), + object_ptr( + _content.get(), + rpl::single(QString()), + st::postsSearchIntroButton)) + )->entity(); + _button->setTextTransform(Ui::RoundButton::TextTransform::NoTransform); + _footer = _content->add( + object_ptr( + _content.get(), + std::move(footer), + st::postsSearchIntroFooter), + st::postsSearchIntroFooterMargin); + _footer->setTryMakeSimilarLines(true); + + _state.value( + ) | rpl::start_with_next([=](const PostsSearchIntroState &state) { + auto copy = _button->children(); + for (const auto child : copy) { + delete child; + } + if (state.needsPremium) { + _button->setText(tr::lng_posts_subscribe()); + } else if (state.freeSearchesLeft > 0) { + _button->setText(tr::lng_posts_search_button( + lt_query, + rpl::single(state.query))); + } else { + _button->setText(rpl::single(QString())); + + Ui::SetButtonTwoLabels( + _button, + tr::lng_posts_limit_search_paid( + lt_cost, + rpl::single(Ui::Text::IconEmoji( + &st::starIconEmoji + ).append( + Lang::FormatCountDecimal(state.starsPerPaidSearch))), + Ui::Text::WithEntities), + tr::lng_posts_limit_unlocks( + lt_duration, + FormatCountdownTill( + state.nextFreeSearchTime + ) | Ui::Text::ToWithEntities(), + Ui::Text::WithEntities), + st::resaleButtonTitle, + st::resaleButtonSubtitle); + } + }, _button->lifetime()); +} + +void PostsSearchIntro::resizeEvent(QResizeEvent *e) { + _content->resizeToWidth(width()); + const auto top = std::max(0, (height() - _content->height()) / 3); + _content->move(0, top); +} + +} // namespace Dialogs diff --git a/Telegram/SourceFiles/dialogs/ui/posts_search_intro.h b/Telegram/SourceFiles/dialogs/ui/posts_search_intro.h new file mode 100644 index 0000000000..cebd6e512a --- /dev/null +++ b/Telegram/SourceFiles/dialogs/ui/posts_search_intro.h @@ -0,0 +1,59 @@ +/* +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" + +namespace Ui { +class FlatLabel; +class RoundButton; +class VerticalLayout; +} // namespace Ui + +namespace Dialogs { + +struct PostsSearchIntroState { + QString query; + int freeSearchesPerDay = 0; + int freeSearchesLeft = 0; + TimeId nextFreeSearchTime = 0; + uint32 starsPerPaidSearch : 31 = 0; + uint32 needsPremium : 1 = 0; + + friend inline bool operator==( + PostsSearchIntroState, + PostsSearchIntroState) = default; +}; + +class PostsSearchIntro final : public Ui::RpWidget { +public: + PostsSearchIntro( + not_null parent, + PostsSearchIntroState state); + ~PostsSearchIntro(); + + void update(PostsSearchIntroState state); + + [[nodiscard]] rpl::producer searchWithStars() const; + +private: + void resizeEvent(QResizeEvent *e) override; + + void setup(); + + rpl::variable _state; + + std::unique_ptr _content; + Ui::FlatLabel *_title = nullptr; + Ui::FlatLabel *_subtitle = nullptr; + Ui::RoundButton *_button = nullptr; + Ui::FlatLabel *_footer = nullptr; + +}; + +} // namespace Dialogs diff --git a/Telegram/SourceFiles/settings/settings_credits_graphics.cpp b/Telegram/SourceFiles/settings/settings_credits_graphics.cpp index 6a44af2a85..c49994a9bd 100644 --- a/Telegram/SourceFiles/settings/settings_credits_graphics.cpp +++ b/Telegram/SourceFiles/settings/settings_credits_graphics.cpp @@ -17,7 +17,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/unixtime.h" #include "boxes/gift_premium_box.h" #include "boxes/share_box.h" -#include "boxes/send_credits_box.h" // SetButtonTwoLabels #include "boxes/star_gift_box.h" #include "boxes/transfer_gift_box.h" #include "chat_helpers/stickers_gift_box_pack.h" @@ -64,6 +63,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "settings/settings_credits.h" #include "statistics/widgets/chart_header_widget.h" #include "ui/boxes/confirm_box.h" +#include "ui/controls/button_two_labels.h" #include "ui/controls/ton_common.h" #include "ui/controls/userpic_button.h" #include "ui/dynamic_image.h" diff --git a/Telegram/SourceFiles/ui/controls/button_two_labels.cpp b/Telegram/SourceFiles/ui/controls/button_two_labels.cpp new file mode 100644 index 0000000000..2a9bf513f4 --- /dev/null +++ b/Telegram/SourceFiles/ui/controls/button_two_labels.cpp @@ -0,0 +1,57 @@ +/* +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 "ui/controls/button_two_labels.h" + +#include "ui/widgets/labels.h" + +namespace Ui { + +void SetButtonTwoLabels( + not_null button, + rpl::producer title, + rpl::producer subtitle, + const style::FlatLabel &st, + const style::FlatLabel &subst, + const style::color *textFg) { + const auto buttonTitle = Ui::CreateChild( + button, + std::move(title), + st); + const auto buttonSubtitle = Ui::CreateChild( + button, + std::move(subtitle), + subst); + buttonSubtitle->setOpacity(0.6); + if (textFg) { + buttonTitle->setTextColorOverride((*textFg)->c); + buttonSubtitle->setTextColorOverride((*textFg)->c); + style::PaletteChanged() | rpl::start_with_next([=] { + buttonTitle->setTextColorOverride((*textFg)->c); + buttonSubtitle->setTextColorOverride((*textFg)->c); + }, buttonTitle->lifetime()); + } + rpl::combine( + button->sizeValue(), + buttonTitle->sizeValue(), + buttonSubtitle->sizeValue() + ) | rpl::start_with_next([=](QSize outer, QSize title, QSize subtitle) { + const auto two = title.height() + subtitle.height(); + const auto titleTop = (outer.height() - two) / 2; + const auto subtitleTop = titleTop + title.height(); + buttonTitle->moveToLeft( + (outer.width() - title.width()) / 2, + titleTop); + buttonSubtitle->moveToLeft( + (outer.width() - subtitle.width()) / 2, + subtitleTop); + }, buttonTitle->lifetime()); + buttonTitle->setAttribute(Qt::WA_TransparentForMouseEvents); + buttonSubtitle->setAttribute(Qt::WA_TransparentForMouseEvents); +} + +} // namespace Ui diff --git a/Telegram/SourceFiles/ui/controls/button_two_labels.h b/Telegram/SourceFiles/ui/controls/button_two_labels.h new file mode 100644 index 0000000000..5de559a96c --- /dev/null +++ b/Telegram/SourceFiles/ui/controls/button_two_labels.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 + +namespace style { +struct FlatLabel; +} // namespace style + +namespace Ui { + +class RpWidget; + +void SetButtonTwoLabels( + not_null button, + rpl::producer title, + rpl::producer subtitle, + const style::FlatLabel &st, + const style::FlatLabel &subst, + const style::color *textFg = nullptr); + +} // namespace Ui \ No newline at end of file diff --git a/Telegram/cmake/td_ui.cmake b/Telegram/cmake/td_ui.cmake index bd52902889..75e112d46f 100644 --- a/Telegram/cmake/td_ui.cmake +++ b/Telegram/cmake/td_ui.cmake @@ -109,6 +109,8 @@ PRIVATE dialogs/ui/dialogs_stories_list.h dialogs/ui/dialogs_top_bar_suggestion_content.cpp dialogs/ui/dialogs_top_bar_suggestion_content.h + dialogs/ui/posts_search_intro.cpp + dialogs/ui/posts_search_intro.h dialogs/ui/top_peers_strip.cpp dialogs/ui/top_peers_strip.h @@ -361,6 +363,8 @@ PRIVATE ui/chat/pinned_bar.h ui/chat/requests_bar.cpp ui/chat/requests_bar.h + ui/controls/button_two_labels.cpp + ui/controls/button_two_labels.h ui/controls/call_mute_button.cpp ui/controls/call_mute_button.h ui/controls/chat_service_checkbox.cpp