2
0
mirror of https://github.com/telegramdesktop/tdesktop synced 2025-08-22 02:07:24 +00:00

Initial global posts search implementation.

This commit is contained in:
John Preston 2025-07-30 23:41:41 +04:00
parent 79650cf318
commit 4aab5da7ae
16 changed files with 897 additions and 62 deletions

View File

@ -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

View File

@ -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.";

View File

@ -567,49 +567,6 @@ not_null<FlatLabel*> SetButtonMarkedLabel(
}), st, textFg);
}
void SetButtonTwoLabels(
not_null<Ui::RpWidget*> button,
rpl::producer<TextWithEntities> title,
rpl::producer<TextWithEntities> subtitle,
const style::FlatLabel &st,
const style::FlatLabel &subst,
const style::color *textFg) {
const auto buttonTitle = Ui::CreateChild<Ui::FlatLabel>(
button,
std::move(title),
st);
const auto buttonSubtitle = Ui::CreateChild<Ui::FlatLabel>(
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<Main::Session*> session,
std::shared_ptr<Payments::CreditsFormData> data,

View File

@ -52,14 +52,6 @@ not_null<FlatLabel*> SetButtonMarkedLabel(
const style::FlatLabel &st,
const style::color *textFg = nullptr);
void SetButtonTwoLabels(
not_null<Ui::RpWidget*> button,
rpl::producer<TextWithEntities> title,
rpl::producer<TextWithEntities> subtitle,
const style::FlatLabel &st,
const style::FlatLabel &subst,
const style::color *textFg = nullptr);
void SendStarsForm(
not_null<Main::Session*> session,
std::shared_ptr<Payments::CreditsFormData> data,

View File

@ -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);

View File

@ -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<Ui::PopupMenu*> 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);

View File

@ -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<Main::Session*> session)
: _session(session)
, _api(&_session->api().instance())
, _timer([=] { applyQuery(); })
, _recheckTimer([=] { recheck(); }) {
Data::AmPremiumValue(_session) | rpl::start_with_next([=] {
maybePushPremiumUpdate();
}, _lifetime);
}
rpl::producer<PostsSearchState> 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<not_null<HistoryItem*>>()
: 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<MTPMessage> &messages) {
auto result = std::vector<not_null<HistoryItem*>>();
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<not_null<HistoryItem*>>();
});
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

View File

@ -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<PostsSearchIntroState> intro;
std::vector<not_null<HistoryItem*>> first;
int totalCount = 0;
bool loading = false;
};
class PostsSearch final {
public:
explicit PostsSearch(not_null<Main::Session*> session);
[[nodiscard]] rpl::producer<PostsSearchState> stateUpdates() const;
void setQuery(const QString &query);
void setAllowedStars(int stars);
void requestMore();
private:
struct Entry {
std::vector<std::vector<not_null<HistoryItem*>>> 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<Main::Session*> _session;
MTP::Sender _api;
base::Timer _timer;
base::Timer _recheckTimer;
base::flat_map<QString, Entry> _entries;
std::optional<QString> _query;
std::optional<PostsSearchIntroState> _floodState;
rpl::event_stream<PostsSearchState> _stateUpdates;
rpl::lifetime _lifetime;
};
} // namespace Dialogs

View File

@ -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<Ui::ElasticScroll>(this))
, _appsContent(
_appsScroll->setOwnedWidget(object_ptr<Ui::VerticalLayout>(this)))
, _postsScroll(std::make_unique<Ui::ElasticScroll>(this))
, _postsWrap(_postsScroll->setOwnedWidget(object_ptr<Ui::RpWidget>(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<void(bool)> callback) {
PeerId id,
Fn<void(bool)> 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<PostsSearch>(&_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<InnerWidget>(
_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<PostsSearchIntro>(_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<void()> 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::Key> 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();

View File

@ -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<Window::SessionController*> _controller;
const std::unique_ptr<Ui::ScrollArea> _tabsScroll;
@ -240,6 +251,12 @@ private:
const std::unique_ptr<Ui::ElasticScroll> _appsScroll;
const not_null<Ui::VerticalLayout*> _appsContent;
std::unique_ptr<PostsSearch> _postsSearch;
const std::unique_ptr<Ui::ElasticScroll> _postsScroll;
const not_null<Ui::RpWidget*> _postsWrap;
PostsSearchIntro *_postsSearchIntro = nullptr;
InnerWidget *_postsContent = nullptr;
rpl::producer<> _recentAppsRefreshed;
Fn<bool(not_null<PeerData*>)> _recentAppsShows;
const std::unique_ptr<ObjectList> _recentApps;

View File

@ -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<QString> 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<Ui::RpWidget*> parent,
PostsSearchIntroState state)
: RpWidget(parent)
, _state(std::move(state))
, _content(std::make_unique<Ui::VerticalLayout>(this)) {
setup();
}
PostsSearchIntro::~PostsSearchIntro() = default;
void PostsSearchIntro::update(PostsSearchIntroState state) {
_state = std::move(state);
}
rpl::producer<int> 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<Ui::FlatLabel>(
_content.get(),
std::move(title),
st::postsSearchIntroTitle),
st::postsSearchIntroTitleMargin);
_title->setTryMakeSimilarLines(true);
_subtitle = _content->add(
object_ptr<Ui::FlatLabel>(
_content.get(),
std::move(subtitle),
st::postsSearchIntroSubtitle),
st::postsSearchIntroSubtitleMargin);
_subtitle->setTryMakeSimilarLines(true);
_button = _content->add(
object_ptr<Ui::CenterWrap<Ui::RoundButton>>(
_content.get(),
object_ptr<Ui::RoundButton>(
_content.get(),
rpl::single(QString()),
st::postsSearchIntroButton))
)->entity();
_button->setTextTransform(Ui::RoundButton::TextTransform::NoTransform);
_footer = _content->add(
object_ptr<Ui::FlatLabel>(
_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

View File

@ -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<Ui::RpWidget*> parent,
PostsSearchIntroState state);
~PostsSearchIntro();
void update(PostsSearchIntroState state);
[[nodiscard]] rpl::producer<int> searchWithStars() const;
private:
void resizeEvent(QResizeEvent *e) override;
void setup();
rpl::variable<PostsSearchIntroState> _state;
std::unique_ptr<Ui::VerticalLayout> _content;
Ui::FlatLabel *_title = nullptr;
Ui::FlatLabel *_subtitle = nullptr;
Ui::RoundButton *_button = nullptr;
Ui::FlatLabel *_footer = nullptr;
};
} // namespace Dialogs

View File

@ -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"

View File

@ -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<Ui::RpWidget*> button,
rpl::producer<TextWithEntities> title,
rpl::producer<TextWithEntities> subtitle,
const style::FlatLabel &st,
const style::FlatLabel &subst,
const style::color *textFg) {
const auto buttonTitle = Ui::CreateChild<Ui::FlatLabel>(
button,
std::move(title),
st);
const auto buttonSubtitle = Ui::CreateChild<Ui::FlatLabel>(
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

View File

@ -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<Ui::RpWidget*> button,
rpl::producer<TextWithEntities> title,
rpl::producer<TextWithEntities> subtitle,
const style::FlatLabel &st,
const style::FlatLabel &subst,
const style::color *textFg = nullptr);
} // namespace Ui

View File

@ -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