From 6a6452bd5537a1031c262c9838cf42fc168169e5 Mon Sep 17 00:00:00 2001 From: zenparsing Date: Fri, 15 Nov 2024 17:20:08 -0500 Subject: [PATCH] [NTP Next] Add search bar --- browser/brave_content_browser_client.cc | 8 +- browser/ui/webui/brave_new_tab/BUILD.gn | 5 + .../brave_new_tab/new_tab_page_handler.cc | 165 +++++++++- .../brave_new_tab/new_tab_page_handler.h | 53 +++- .../ui/webui/brave_new_tab/new_tab_page_ui.cc | 61 +++- .../ui/webui/brave_new_tab/new_tab_page_ui.h | 7 + .../ui/webui/brave_new_tab/update_observer.cc | 7 + .../ui/webui/brave_new_tab/update_observer.h | 2 +- components/brave_new_tab/new_tab_page.mojom | 47 +++ .../resources/brave_new_tab_strings.grdp | 44 +++ .../resources/components/app.style.ts | 50 ++- .../resources/components/app.tsx | 7 +- .../resources/components/pcdn_image.tsx | 26 ++ .../resources/components/popover.tsx | 46 +++ .../components/search/engine_icon.tsx | 47 +++ .../components/search/search_box.style.ts | 156 +++++++++ .../components/search/search_box.tsx | 300 ++++++++++++++++++ .../components/search/search_results.style.ts | 120 +++++++ .../components/search/search_results.tsx | 144 +++++++++ .../resources/components/search_context.tsx | 32 ++ .../components/settings/search_panel.style.ts | 62 ++++ .../components/settings/search_panel.tsx | 78 +++++ .../components/settings/settings_modal.tsx | 6 +- .../resources/lib/favicon_url.ts | 8 + .../resources/lib/locale_strings.ts | 12 + .../brave_new_tab/resources/lib/optional.ts | 31 ++ .../brave_new_tab/resources/lib/url_input.ts | 27 ++ .../resources/models/new_tab_model.ts | 2 + .../resources/models/search_model.ts | 88 +++++ .../brave_new_tab/resources/new_tab_page.tsx | 10 +- .../brave_new_tab/resources/stories/index.tsx | 10 +- .../resources/stories/sb_locale.ts | 14 +- .../resources/stories/sb_search_model.ts | 100 ++++++ .../resources/webui/new_tab_page_proxy.ts | 2 +- .../resources/webui/search_box_proxy.ts | 35 ++ .../resources/webui/webui_new_tab_model.ts | 16 + .../resources/webui/webui_search_model.ts | 206 ++++++++++++ 37 files changed, 2013 insertions(+), 21 deletions(-) create mode 100644 components/brave_new_tab/resources/components/pcdn_image.tsx create mode 100644 components/brave_new_tab/resources/components/popover.tsx create mode 100644 components/brave_new_tab/resources/components/search/engine_icon.tsx create mode 100644 components/brave_new_tab/resources/components/search/search_box.style.ts create mode 100644 components/brave_new_tab/resources/components/search/search_box.tsx create mode 100644 components/brave_new_tab/resources/components/search/search_results.style.ts create mode 100644 components/brave_new_tab/resources/components/search/search_results.tsx create mode 100644 components/brave_new_tab/resources/components/search_context.tsx create mode 100644 components/brave_new_tab/resources/components/settings/search_panel.style.ts create mode 100644 components/brave_new_tab/resources/components/settings/search_panel.tsx create mode 100644 components/brave_new_tab/resources/lib/favicon_url.ts create mode 100644 components/brave_new_tab/resources/lib/optional.ts create mode 100644 components/brave_new_tab/resources/lib/url_input.ts create mode 100644 components/brave_new_tab/resources/models/search_model.ts create mode 100644 components/brave_new_tab/resources/stories/sb_search_model.ts create mode 100644 components/brave_new_tab/resources/webui/search_box_proxy.ts create mode 100644 components/brave_new_tab/resources/webui/webui_search_model.ts diff --git a/browser/brave_content_browser_client.cc b/browser/brave_content_browser_client.cc index 5d63fd5e760b..c255ef1a0dd9 100644 --- a/browser/brave_content_browser_client.cc +++ b/browser/brave_content_browser_client.cc @@ -652,6 +652,10 @@ void BraveContentBrowserClient::RegisterWebUIInterfaceBrokers( .Add() .Add(); + auto ntp_next_registration = + registry.ForWebUI() + .Add(); + #if BUILDFLAG(ENABLE_BRAVE_VPN) if (brave_vpn::IsBraveVPNFeatureEnabled()) { ntp_registration.Add(); @@ -660,6 +664,7 @@ void BraveContentBrowserClient::RegisterWebUIInterfaceBrokers( if (base::FeatureList::IsEnabled(features::kBraveNtpSearchWidget)) { ntp_registration.Add(); + ntp_next_registration.Add(); } if (base::FeatureList::IsEnabled( @@ -846,9 +851,6 @@ void BraveContentBrowserClient::RegisterBrowserInterfaceBindersForFrame( content::RegisterWebUIControllerInterfaceBinder< commands::mojom::CommandsService, BraveSettingsUI>(map); } - content::RegisterWebUIControllerInterfaceBinder< - brave_new_tab::mojom::NewTabPageHandler, brave_new_tab::NewTabPageUI>( - map); #endif auto* prefs = diff --git a/browser/ui/webui/brave_new_tab/BUILD.gn b/browser/ui/webui/brave_new_tab/BUILD.gn index 35082483894a..1fc67e37c594 100644 --- a/browser/ui/webui/brave_new_tab/BUILD.gn +++ b/browser/ui/webui/brave_new_tab/BUILD.gn @@ -28,6 +28,8 @@ source_set("brave_new_tab") { "//brave/components/brave_new_tab", "//brave/components/brave_new_tab:mojom", "//brave/components/brave_new_tab/resources:generated_resources", + "//brave/components/brave_private_cdn", + "//brave/components/brave_search_conversion", "//brave/components/l10n/common", "//brave/components/ntp_background_images/browser", "//brave/components/ntp_background_images/common", @@ -36,8 +38,11 @@ source_set("brave_new_tab") { "//chrome/app:generated_resources", "//chrome/browser:browser_public_dependencies", "//chrome/browser/profiles:profile", + "//chrome/browser/search_engines", "//chrome/browser/themes", + "//chrome/browser/ui/browser_window", "//chrome/browser/ui/webui:webui_util", + "//chrome/browser/ui/webui/searchbox", "//components/prefs", "//components/strings:components_strings", "//ui/base", diff --git a/browser/ui/webui/brave_new_tab/new_tab_page_handler.cc b/browser/ui/webui/brave_new_tab/new_tab_page_handler.cc index 8026d004216e..72f3dfea6155 100644 --- a/browser/ui/webui/brave_new_tab/new_tab_page_handler.cc +++ b/browser/ui/webui/brave_new_tab/new_tab_page_handler.cc @@ -7,11 +7,24 @@ #include +#include "base/strings/string_util.h" +#include "base/strings/utf_string_conversions.h" #include "brave/browser/ui/webui/brave_new_tab/background_provider.h" #include "brave/browser/ui/webui/brave_new_tab/custom_image_chooser.h" +#include "brave/components/brave_private_cdn/private_cdn_helper.h" +#include "brave/components/brave_private_cdn/private_cdn_request_helper.h" +#include "brave/components/brave_search_conversion/pref_names.h" #include "brave/components/ntp_background_images/common/pref_names.h" #include "chrome/browser/themes/theme_syncable_service.h" +#include "chrome/browser/ui/browser_window/public/browser_window_interface.h" +#include "chrome/browser/ui/tabs/public/tab_interface.h" +#include "chrome/common/pref_names.h" #include "components/prefs/pref_service.h" +#include "components/search_engines/search_engine_type.h" +#include "components/search_engines/template_url_service.h" +#include "services/network/public/cpp/header_util.h" +#include "ui/base/window_open_disposition_utils.h" +#include "url/gurl.h" namespace brave_new_tab { @@ -19,14 +32,21 @@ NewTabPageHandler::NewTabPageHandler( mojo::PendingReceiver receiver, std::unique_ptr custom_image_chooser, std::unique_ptr background_provider, - PrefService& pref_service) + std::unique_ptr pcdn_helper, + tabs::TabInterface& tab, + PrefService& pref_service, + TemplateURLService& template_url_service) : receiver_(this, std::move(receiver)), update_observer_(pref_service), custom_image_chooser_(std::move(custom_image_chooser)), background_provider_(std::move(background_provider)), - pref_service_(pref_service) { + pcdn_helper_(std::move(pcdn_helper)), + tab_(tab), + pref_service_(pref_service), + template_url_service_(template_url_service) { CHECK(custom_image_chooser_); CHECK(background_provider_); + CHECK(pcdn_helper_); update_observer_.SetCallback(base::BindRepeating(&NewTabPageHandler::OnUpdate, weak_factory_.GetWeakPtr())); @@ -40,6 +60,38 @@ void NewTabPageHandler::SetNewTabPage( page_.Bind(std::move(page)); } +void NewTabPageHandler::LoadResourceFromPcdn( + const std::string& url, + LoadResourceFromPcdnCallback callback) { + GURL resource_url(url); + if (!resource_url.is_valid()) { + std::move(callback).Run(std::nullopt); + return; + } + + auto on_resource_downloaded = [](decltype(callback) callback, bool is_padded, + int response_code, const std::string& body) { + if (!network::IsSuccessfulStatus(response_code)) { + std::move(callback).Run(std::nullopt); + return; + } + std::string_view body_view(body); + if (is_padded) { + if (!brave::PrivateCdnHelper::GetInstance()->RemovePadding(&body_view)) { + std::move(callback).Run(std::nullopt); + return; + } + } + std::move(callback).Run( + std::vector(body_view.begin(), body_view.end())); + }; + + pcdn_helper_->DownloadToString( + resource_url, + base::BindOnce(on_resource_downloaded, std::move(callback), + base::EndsWith(resource_url.path(), ".pad"))); +} + void NewTabPageHandler::GetBackgroundsEnabled( GetBackgroundsEnabledCallback callback) { bool backgrounds_enabled = pref_service_->GetBoolean( @@ -120,6 +172,112 @@ void NewTabPageHandler::RemoveCustomBackground( std::move(callback)); } +void NewTabPageHandler::GetShowSearchBox(GetShowSearchBoxCallback callback) { + std::move(callback).Run(pref_service_->GetBoolean( + brave_search_conversion::prefs::kShowNTPSearchBox)); +} + +void NewTabPageHandler::SetShowSearchBox(bool show_search_box, + SetShowSearchBoxCallback callback) { + pref_service_->SetBoolean(brave_search_conversion::prefs::kShowNTPSearchBox, + show_search_box); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetSearchSuggestionsEnabled( + GetSearchSuggestionsEnabledCallback callback) { + std::move(callback).Run( + pref_service_->GetBoolean(prefs::kSearchSuggestEnabled)); +} + +void NewTabPageHandler::SetSearchSuggestionsEnabled( + bool enabled, + SetSearchSuggestionsEnabledCallback callback) { + pref_service_->SetBoolean(prefs::kSearchSuggestEnabled, enabled); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetSearchSuggestionsPromptDismissed( + GetSearchSuggestionsPromptDismissedCallback callback) { + std::move(callback).Run( + pref_service_->GetBoolean(brave_search_conversion::prefs::kDismissed)); +} + +void NewTabPageHandler::SetSearchSuggestionsPromptDismissed( + bool dismissed, + SetSearchSuggestionsPromptDismissedCallback callback) { + pref_service_->SetBoolean(brave_search_conversion::prefs::kDismissed, + dismissed); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetLastUsedSearchEngine( + GetLastUsedSearchEngineCallback callback) { + std::move(callback).Run(pref_service_->GetString( + brave_search_conversion::prefs::kLastUsedNTPSearchEngine)); +} + +void NewTabPageHandler::SetLastUsedSearchEngine( + const std::string& engine_host, + SetLastUsedSearchEngineCallback callback) { + pref_service_->SetString( + brave_search_conversion::prefs::kLastUsedNTPSearchEngine, engine_host); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetAvailableSearchEngines( + GetAvailableSearchEnginesCallback callback) { + std::vector search_engines; + for (auto template_url : template_url_service_->GetTemplateURLs()) { + if (template_url->GetBuiltinEngineType() != + BuiltinEngineType::KEYWORD_MODE_PREPOPULATED_ENGINE) { + continue; + } + auto search_engine = mojom::SearchEngineInfo::New(); + search_engine->prepopulate_id = template_url->prepopulate_id(); + search_engine->host = GURL(template_url->url()).host(); + if (search_engine->host.empty()) { + search_engine->host = "google.com"; + } + search_engine->name = base::UTF16ToUTF8(template_url->short_name()); + search_engine->keyword = base::UTF16ToUTF8(template_url->keyword()); + search_engine->favicon_url = template_url->favicon_url().spec(); + search_engines.push_back(std::move(search_engine)); + } + std::move(callback).Run(std::move(search_engines)); +} + +void NewTabPageHandler::OpenSearch(const std::string& query, + const std::string& engine, + mojom::EventDetailsPtr details, + OpenSearchCallback callback) { + auto* template_url = template_url_service_->GetTemplateURLForHost(engine); + if (!template_url) { + std::move(callback).Run(); + return; + } + + GURL search_url = template_url->GenerateSearchURL( + template_url_service_->search_terms_data(), base::UTF8ToUTF16(query)); + + tab_->GetBrowserWindowInterface()->OpenGURL( + search_url, + ui::DispositionFromClick(false, details->alt_key, details->ctrl_key, + details->meta_key, details->shift_key)); + + std::move(callback).Run(); +} + +void NewTabPageHandler::OpenURLFromSearch(const std::string& url, + mojom::EventDetailsPtr details, + OpenURLFromSearchCallback callback) { + tab_->GetBrowserWindowInterface()->OpenGURL( + GURL(url), + ui::DispositionFromClick(false, details->alt_key, details->ctrl_key, + details->meta_key, details->shift_key)); + std::move(callback).Run(); +} + void NewTabPageHandler::OnCustomBackgroundsSelected( ShowCustomBackgroundChooserCallback callback, std::vector paths) { @@ -142,6 +300,9 @@ void NewTabPageHandler::OnUpdate(UpdateObserver::Source update_source) { case UpdateObserver::Source::kBackgroundPrefs: page_->OnBackgroundPrefsUpdated(); break; + case UpdateObserver::Source::kSearchPrefs: + page_->OnSearchPrefsUpdated(); + break; } } diff --git a/browser/ui/webui/brave_new_tab/new_tab_page_handler.h b/browser/ui/webui/brave_new_tab/new_tab_page_handler.h index e93f224efa52..1e2b92356faf 100644 --- a/browser/ui/webui/brave_new_tab/new_tab_page_handler.h +++ b/browser/ui/webui/brave_new_tab/new_tab_page_handler.h @@ -20,6 +20,15 @@ #include "mojo/public/cpp/bindings/remote.h" class PrefService; +class TemplateURLService; + +namespace brave_private_cdn { +class PrivateCDNRequestHelper; +} + +namespace tabs { +class TabInterface; +} namespace brave_new_tab { @@ -28,15 +37,21 @@ class CustomImageChooser; class NewTabPageHandler : public mojom::NewTabPageHandler { public: - NewTabPageHandler(mojo::PendingReceiver receiver, - std::unique_ptr custom_image_chooser, - std::unique_ptr background_provider, - PrefService& pref_service); + NewTabPageHandler( + mojo::PendingReceiver receiver, + std::unique_ptr custom_image_chooser, + std::unique_ptr background_provider, + std::unique_ptr pcdn_helper, + tabs::TabInterface& tab, + PrefService& pref_service, + TemplateURLService& template_url_service); ~NewTabPageHandler() override; // mojom::NewTabPageHandler: void SetNewTabPage(mojo::PendingRemote page) override; + void LoadResourceFromPcdn(const std::string& url, + LoadResourceFromPcdnCallback callback) override; void GetBackgroundsEnabled(GetBackgroundsEnabledCallback callback) override; void SetBackgroundsEnabled(bool enabled, SetBackgroundsEnabledCallback callback) override; @@ -58,6 +73,33 @@ class NewTabPageHandler : public mojom::NewTabPageHandler { ShowCustomBackgroundChooserCallback callback) override; void RemoveCustomBackground(const std::string& background_url, RemoveCustomBackgroundCallback callback) override; + void GetShowSearchBox(GetShowSearchBoxCallback callback) override; + void SetShowSearchBox(bool show_search_box, + SetShowSearchBoxCallback callback) override; + void GetSearchSuggestionsEnabled( + GetSearchSuggestionsEnabledCallback callback) override; + void SetSearchSuggestionsEnabled( + bool enabled, + SetSearchSuggestionsEnabledCallback callback) override; + void GetSearchSuggestionsPromptDismissed( + GetSearchSuggestionsPromptDismissedCallback callback) override; + void SetSearchSuggestionsPromptDismissed( + bool dismissed, + SetSearchSuggestionsPromptDismissedCallback callback) override; + void GetLastUsedSearchEngine( + GetLastUsedSearchEngineCallback callback) override; + void SetLastUsedSearchEngine( + const std::string& engine_host, + SetLastUsedSearchEngineCallback callback) override; + void GetAvailableSearchEngines( + GetAvailableSearchEnginesCallback callback) override; + void OpenSearch(const std::string& query, + const std::string& engine, + mojom::EventDetailsPtr details, + OpenSearchCallback callback) override; + void OpenURLFromSearch(const std::string& url, + mojom::EventDetailsPtr details, + OpenURLFromSearchCallback callback) override; private: void OnCustomBackgroundsSelected(ShowCustomBackgroundChooserCallback callback, @@ -70,7 +112,10 @@ class NewTabPageHandler : public mojom::NewTabPageHandler { UpdateObserver update_observer_; std::unique_ptr custom_image_chooser_; std::unique_ptr background_provider_; + std::unique_ptr pcdn_helper_; + raw_ref tab_; raw_ref pref_service_; + raw_ref template_url_service_; base::WeakPtrFactory weak_factory_{this}; }; diff --git a/browser/ui/webui/brave_new_tab/new_tab_page_ui.cc b/browser/ui/webui/brave_new_tab/new_tab_page_ui.cc index 66e2dfecb2db..f0c608c58135 100644 --- a/browser/ui/webui/brave_new_tab/new_tab_page_ui.cc +++ b/browser/ui/webui/brave_new_tab/new_tab_page_ui.cc @@ -16,16 +16,23 @@ #include "brave/browser/ui/webui/brave_webui_source.h" #include "brave/components/brave_new_tab/new_tab_prefs.h" #include "brave/components/brave_new_tab/resources/grit/brave_new_tab_generated_map.h" +#include "brave/components/brave_private_cdn/private_cdn_request_helper.h" #include "brave/components/l10n/common/localization_util.h" #include "brave/components/ntp_background_images/browser/ntp_custom_images_source.h" #include "chrome/browser/profiles/profile.h" +#include "chrome/browser/search_engines/template_url_service_factory.h" +#include "chrome/browser/ui/tabs/public/tab_interface.h" +#include "chrome/browser/ui/webui/favicon_source.h" +#include "chrome/browser/ui/webui/searchbox/realbox_handler.h" #include "chrome/browser/ui/webui/webui_util.h" +#include "components/favicon_base/favicon_url_parser.h" #include "components/grit/brave_components_resources.h" #include "components/grit/brave_components_strings.h" #include "components/prefs/pref_service.h" #include "components/strings/grit/components_strings.h" #include "content/public/browser/web_ui.h" #include "content/public/browser/web_ui_data_source.h" +#include "net/traffic_annotation/network_traffic_annotation.h" #include "ui/base/webui/web_ui_util.h" namespace brave_new_tab { @@ -37,18 +44,51 @@ static constexpr webui::LocalizedString kStrings[] = { {"braveBackgroundLabel", IDS_NEW_TAB_BRAVE_BACKGROUND_LABEL}, {"customBackgroundLabel", IDS_NEW_TAB_CUSTOM_BACKGROUND_LABEL}, {"customBackgroundTitle", IDS_NEW_TAB_CUSTOM_BACKGROUND_LABEL}, + {"customizeSearchEnginesLink", IDS_NEW_TAB_CUSTOMIZE_SEARCH_ENGINES_LINK}, + {"enabledSearchEnginesLabel", IDS_NEW_TAB_ENABLED_SEARCH_ENGINES_LABEL}, {"gradientBackgroundLabel", IDS_NEW_TAB_GRADIENT_BACKGROUND_LABEL}, {"gradientBackgroundTitle", IDS_NEW_TAB_GRADIENT_BACKGROUND_LABEL}, {"photoCreditsText", IDS_NEW_TAB_PHOTO_CREDITS_TEXT}, {"randomizeBackgroundLabel", IDS_NEW_TAB_RANDOMIZE_BACKGROUND_LABEL}, + {"searchAskLeoDescription", IDS_OMNIBOX_ASK_LEO_DESCRIPTION}, + {"searchBoxPlaceholderText", IDS_NEW_TAB_SEARCH_BOX_PLACEHOLDER_TEXT}, + {"searchBoxPlaceholderTextBrave", + IDS_NEW_TAB_SEARCH_BOX_PLACEHOLDER_TEXT_BRAVE}, + {"searchCustomizeEngineListText", + IDS_NEW_TAB_SEARCH_CUSTOMIZE_ENGINE_LIST_TEXT}, + {"searchSettingsTitle", IDS_NEW_TAB_SEARCH_SETTINGS_TITLE}, + {"searchSuggestionsDismissButtonLabel", + IDS_NEW_TAB_SEARCH_SUGGESTIONS_DISMISS_BUTTON_LABEL}, + {"searchSuggestionsEnableButtonLabel", + IDS_NEW_TAB_SEARCH_SUGGESTIONS_ENABLE_BUTTON_LABEL}, + {"searchSuggestionsPromptText", IDS_NEW_TAB_SEARCH_SUGGESTIONS_PROMPT_TEXT}, + {"searchSuggestionsPromptTitle", + IDS_NEW_TAB_SEARCH_SUGGESTIONS_PROMPT_TITLE}, {"settingsTitle", IDS_NEW_TAB_SETTINGS_TITLE}, {"showBackgroundsLabel", IDS_NEW_TAB_SHOW_BACKGROUNDS_LABEL}, + {"showSearchBoxLabel", IDS_NEW_TAB_SHOW_SEARCH_BOX_LABEL}, {"showSponsoredImagesLabel", IDS_NEW_TAB_SHOW_SPONSORED_IMAGES_LABEL}, {"solidBackgroundLabel", IDS_NEW_TAB_SOLID_BACKGROUND_LABEL}, {"solidBackgroundTitle", IDS_NEW_TAB_SOLID_BACKGROUND_LABEL}, {"uploadBackgroundLabel", IDS_NEW_TAB_UPLOAD_BACKGROUND_LABEL}, }; +constexpr auto kPcdnImageLoaderTrafficAnnotation = + net::DefineNetworkTrafficAnnotation("brave_new_tab_pcdn_loader", + R"( + semantics { + sender: "Brave New Tab WebUI" + description: "Fetches resource data from the Brave private CDN." + trigger: "Loading images on the new tab page." + data: "No data sent, other than URL of the resource." + destination: BRAVE_OWNED_SERVICE + } + policy { + cookies_allowed: NO + setting: "None" + } + )"); + // Adds support for displaying images stored in the custom background image // folder. void AddCustomImageDataSource(Profile* profile) { @@ -95,6 +135,10 @@ NewTabPageUI::NewTabPageUI(content::WebUI* web_ui) AddBackgroundColorToSource(source, web_ui->GetWebContents()); AddCustomImageDataSource(profile); + content::URLDataSource::Add( + profile, std::make_unique( + profile, chrome::FaviconUrlFormat::kFavicon2)); + web_ui->OverrideTitle( brave_l10n::GetLocalizedResourceUTF16String(IDS_NEW_TAB_TITLE)); @@ -105,6 +149,7 @@ NewTabPageUI::~NewTabPageUI() = default; void NewTabPageUI::BindInterface( mojo::PendingReceiver pending_receiver) { + auto* web_contents = web_ui()->GetWebContents(); auto* profile = Profile::FromWebUI(web_ui()); auto* prefs = profile->GetPrefs(); @@ -116,9 +161,23 @@ void NewTabPageUI::BindInterface( std::make_unique(profile), *prefs, ntp_background_images::ViewCounterServiceFactory::GetForProfile(profile)); + auto pcdn_helper = + std::make_unique( + kPcdnImageLoaderTrafficAnnotation, profile->GetURLLoaderFactory()); + page_handler_ = std::make_unique( std::move(pending_receiver), std::move(image_chooser), - std::move(background_provider), *prefs); + std::move(background_provider), std::move(pcdn_helper), + *tabs::TabInterface::GetFromContents(web_contents), *prefs, + *TemplateURLServiceFactory::GetForProfile(profile)); +} + +void NewTabPageUI::BindInterface( + mojo::PendingReceiver pending_receiver) { + realbox_handler_ = std::make_unique( + std::move(pending_receiver), Profile::FromWebUI(web_ui()), + web_ui()->GetWebContents(), /*metrics_reporter=*/nullptr, + /*lens_searchbox_client=*/nullptr, /*omnibox_controller=*/nullptr); } WEB_UI_CONTROLLER_TYPE_IMPL(NewTabPageUI) diff --git a/browser/ui/webui/brave_new_tab/new_tab_page_ui.h b/browser/ui/webui/brave_new_tab/new_tab_page_ui.h index df5ac8aaeb5f..9bba1ccb3d6b 100644 --- a/browser/ui/webui/brave_new_tab/new_tab_page_ui.h +++ b/browser/ui/webui/brave_new_tab/new_tab_page_ui.h @@ -12,11 +12,14 @@ #include "chrome/common/webui_url_constants.h" #include "mojo/public/cpp/bindings/pending_receiver.h" #include "ui/webui/mojo_web_ui_controller.h" +#include "ui/webui/resources/cr_components/searchbox/searchbox.mojom.h" namespace content { class WebUI; } +class RealboxHandler; + namespace brave_new_tab { // The Web UI controller for the Brave new tab page. @@ -28,8 +31,12 @@ class NewTabPageUI : public ui::MojoWebUIController { void BindInterface( mojo::PendingReceiver pending_receiver); + void BindInterface( + mojo::PendingReceiver pending_reciever); + private: std::unique_ptr page_handler_; + std::unique_ptr realbox_handler_; WEB_UI_CONTROLLER_TYPE_DECL(); }; diff --git a/browser/ui/webui/brave_new_tab/update_observer.cc b/browser/ui/webui/brave_new_tab/update_observer.cc index 451a8e455630..5250ce39515c 100644 --- a/browser/ui/webui/brave_new_tab/update_observer.cc +++ b/browser/ui/webui/brave_new_tab/update_observer.cc @@ -8,7 +8,9 @@ #include #include "brave/browser/ntp_background/ntp_background_prefs.h" +#include "brave/components/brave_search_conversion/pref_names.h" #include "brave/components/ntp_background_images/common/pref_names.h" +#include "chrome/common/pref_names.h" namespace brave_new_tab { @@ -22,6 +24,11 @@ UpdateObserver::UpdateObserver(PrefService& pref_service) { AddPrefListener(NTPBackgroundPrefs::kPrefName, Source::kBackgroundPrefs); AddPrefListener(NTPBackgroundPrefs::kCustomImageListPrefName, Source::kBackgroundPrefs); + AddPrefListener(brave_search_conversion::prefs::kShowNTPSearchBox, + Source::kSearchPrefs); + AddPrefListener(prefs::kSearchSuggestEnabled, Source::kSearchPrefs); + AddPrefListener(brave_search_conversion::prefs::kDismissed, + Source::kSearchPrefs); } UpdateObserver::~UpdateObserver() = default; diff --git a/browser/ui/webui/brave_new_tab/update_observer.h b/browser/ui/webui/brave_new_tab/update_observer.h index f90bff3b0b95..82e6b968f4cd 100644 --- a/browser/ui/webui/brave_new_tab/update_observer.h +++ b/browser/ui/webui/brave_new_tab/update_observer.h @@ -20,7 +20,7 @@ namespace brave_new_tab { // new tab page. class UpdateObserver { public: - enum class Source { kBackgroundPrefs }; + enum class Source { kBackgroundPrefs, kSearchPrefs }; explicit UpdateObserver(PrefService& pref_service); ~UpdateObserver(); diff --git a/components/brave_new_tab/new_tab_page.mojom b/components/brave_new_tab/new_tab_page.mojom index 64c50e9b060f..57a543d9e232 100644 --- a/components/brave_new_tab/new_tab_page.mojom +++ b/components/brave_new_tab/new_tab_page.mojom @@ -37,12 +37,30 @@ struct SponsoredImageBackground { SponsoredImageLogo? logo; }; +struct SearchEngineInfo { + int64 prepopulate_id; + string name; + string keyword; + string host; + string favicon_url; +}; + +struct EventDetails { + bool alt_key; + bool ctrl_key; + bool meta_key; + bool shift_key; +}; + // WebUI-side handler for notifications from the browser. interface NewTabPage { // Called when a background-related profile preference has been updated. OnBackgroundPrefsUpdated(); + // Called when search-related profile preference has been updated. + OnSearchPrefsUpdated(); + }; // Browser-side handler for requests from the WebUI page. @@ -52,6 +70,9 @@ interface NewTabPageHandler { // the browser. SetNewTabPage(pending_remote page); + // Loads a binary resource from Brave's private CDN. + LoadResourceFromPcdn(string url) => (array? resource_data); + // Gets or sets whether the user has enabled background images or colors on // the new tab page. GetBackgroundsEnabled() => (bool enabled); @@ -91,4 +112,30 @@ interface NewTabPageHandler { // backgrounds. RemoveCustomBackground(string background_url) => (); + // Gets or sets whether the search box is displayed on the NTP. + GetShowSearchBox() => (bool show_search_box); + SetShowSearchBox(bool show_search_box) => (); + + // Gets or sets whether search suggestions are enabled. + GetSearchSuggestionsEnabled() => (bool enabled); + SetSearchSuggestionsEnabled(bool enabled) => (); + + // Gets or sets whether the prompt to enable search suggestions has been + // dismissed. + GetSearchSuggestionsPromptDismissed() => (bool dismissed); + SetSearchSuggestionsPromptDismissed(bool dismissed) => (); + + // Gets or sets the last used search engine. + GetLastUsedSearchEngine() => (string engine); + SetLastUsedSearchEngine(string engine) => (); + + // Returns the list of available search engines for use on the NTP search box. + GetAvailableSearchEngines() => (array search_engines); + + // Opens search for the specified query and engine. + OpenSearch(string query, string engine, EventDetails details) => (); + + // Opens a URL from the search box. + OpenURLFromSearch(string url, EventDetails details) => (); + }; diff --git a/components/brave_new_tab/resources/brave_new_tab_strings.grdp b/components/brave_new_tab/resources/brave_new_tab_strings.grdp index 69911b5c01d3..561ec51cd361 100644 --- a/components/brave_new_tab/resources/brave_new_tab_strings.grdp +++ b/components/brave_new_tab/resources/brave_new_tab_strings.grdp @@ -12,6 +12,14 @@ Use your own + + Customize available engines + + + + Enabled search engines + + Gradients @@ -24,6 +32,38 @@ Refresh on every new tab + + Search the web + + + + Ask Brave Search + + + + Customize list + + + + Search + + + + No thanks + + + + Enable + + + + When you search, what you type will be sent to your search engine for better suggestions. + + + + Enable search suggestions? + + Customize Dashboard @@ -32,6 +72,10 @@ Show Background Images + + Show search widget in new tabs + + Show Sponsored Images diff --git a/components/brave_new_tab/resources/components/app.style.ts b/components/brave_new_tab/resources/components/app.style.ts index 6a9d74ef82dd..cb28c3c8eabd 100644 --- a/components/brave_new_tab/resources/components/app.style.ts +++ b/components/brave_new_tab/resources/components/app.style.ts @@ -35,12 +35,13 @@ export const style = scoped.css` } .topsites-container { - min-height: 32px; + margin: 16px 0; } .searchbox-container { flex: 1 1 auto; margin: 16px 0; + align-self: stretch; } .background-caption-container { @@ -90,5 +91,52 @@ global.css` font: ${font.heading.h4}; margin: 0; } + + p { + margin: 0; + } + + dialog, [popover] { + border: none; + color: inherit; + margin: 0; + padding: 0; + background: none; + + &::backdrop { + background-color: transparent; + } + } + + .popover-menu { + padding: 4px; + border-radius: 8px; + border: solid 1px ${color.divider.subtle}; + background: ${color.container.background}; + box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; + gap: 4px; + min-width: 180px; + + .divider { + height: 1px; + background: ${color.divider.subtle}; + } + + button { + --leo-icon-size: 20px; + + padding: 8px 24px 8px 8px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 16px; + + &:hover, &.highlight { + background: ${color.container.highlight}; + } + } + } } ` diff --git a/components/brave_new_tab/resources/components/app.tsx b/components/brave_new_tab/resources/components/app.tsx index ea521ceae0dc..944148591027 100644 --- a/components/brave_new_tab/resources/components/app.tsx +++ b/components/brave_new_tab/resources/components/app.tsx @@ -6,6 +6,7 @@ import * as React from 'react' import Icon from '@brave/leo/react/icon' +import { SearchBox } from './search/search_box' import { Background } from './background' import { BackgroundCaption } from './background_caption' import { SettingsModal, SettingsView } from './settings/settings_modal' @@ -26,7 +27,11 @@ export function App() {
-
+
+ setSettingsView('search')} + /> +
diff --git a/components/brave_new_tab/resources/components/pcdn_image.tsx b/components/brave_new_tab/resources/components/pcdn_image.tsx new file mode 100644 index 000000000000..a70c76aef94a --- /dev/null +++ b/components/brave_new_tab/resources/components/pcdn_image.tsx @@ -0,0 +1,26 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import * as React from 'react' + +import { useNewTabModel } from './new_tab_context' +import { placeholderImageSrc } from '../lib/image_loader' + +interface Props { + src: string + className?: string +} + +export function PcdnImage(props: Props) { + const model = useNewTabModel() + const [imageURL, setImageURL] = React.useState(placeholderImageSrc) + + React.useEffect(() => { + setImageURL(placeholderImageSrc) + model.getPcdnImageURL(props.src).then(setImageURL) + }, [props.src]) + + return +} diff --git a/components/brave_new_tab/resources/components/popover.tsx b/components/brave_new_tab/resources/components/popover.tsx new file mode 100644 index 000000000000..28f6ff8c174e --- /dev/null +++ b/components/brave_new_tab/resources/components/popover.tsx @@ -0,0 +1,46 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import * as React from 'react' + +interface Props { + className?: string + isOpen: boolean + children: React.ReactNode + onClose: () => void +} + +export function Popover(props: Props) { + const elementRef = React.useRef(null) + + React.useEffect(() => { + elementRef.current?.setAttribute('popover', 'auto') + }, []) + + React.useEffect(() => { + if (props.isOpen) { + elementRef.current?.showPopover() + } else { + elementRef.current?.hidePopover() + } + }, [props.isOpen]) + + React.useEffect(() => { + const onToggle = (event: ToggleEvent) => { + if (event.newState === 'closed') { + props.onClose() + } + } + const elem = elementRef.current + elem?.addEventListener('toggle', onToggle) + return () => elem?.removeEventListener('toggle', onToggle) + }, [props.onClose]) + + return ( +
+ {props.children} +
+ ) +} diff --git a/components/brave_new_tab/resources/components/search/engine_icon.tsx b/components/brave_new_tab/resources/components/search/engine_icon.tsx new file mode 100644 index 000000000000..1126087a61a6 --- /dev/null +++ b/components/brave_new_tab/resources/components/search/engine_icon.tsx @@ -0,0 +1,47 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import * as React from 'react' +import Icon from '@brave/leo/react/icon' + +import { SearchEngineInfo } from '../../models/search_model' +import { PcdnImage } from '../pcdn_image' + +function getNamedIcon(engineHost: string) { + switch (engineHost) { + case 'google.com': + return 'google-color' + case 'duckduckgo.com': + return 'duckduckgo-color' + case 'search.brave.com': + return 'social-brave-release-favicon-fullheight-color' + case 'www.bing.com': + return 'bing-color' + case 'www.qwant.com': + return 'qwant-color' + case 'www.startpage.com': + return 'startpage-color' + case 'search.yahoo.com': + return 'yahoo-color' + case 'yandex.com': + return 'yandex-color' + case 'www.ecosia.org': + return 'ecosia-color' + } + return '' +} + +interface Props { + engine: SearchEngineInfo +} + +export function EngineIcon(props: Props) { + const { engine } = props + const iconName = getNamedIcon(engine.host) + if (iconName) { + return + } + return +} diff --git a/components/brave_new_tab/resources/components/search/search_box.style.ts b/components/brave_new_tab/resources/components/search/search_box.style.ts new file mode 100644 index 000000000000..b70d215c4dd1 --- /dev/null +++ b/components/brave_new_tab/resources/components/search/search_box.style.ts @@ -0,0 +1,156 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { color, effect } from '@brave/leo/tokens/css/variables' +import { scoped } from '../../lib/scoped_css' + +export const style = scoped.css` + + & { + --self-transition-duration: 200ms; + + anchor-name: --search-box-anchor; + color: ${color.text.primary}; + } + + .search-container { + position: absolute; + position-anchor: --search-box-anchor; + inset: anchor(start) 0 auto; + + display: block; + margin: 0 auto; + overflow: visible; + width: calc(100vw - 32px); + max-width: 393px; + + transition-property: overlay, max-width, inset-block-start; + transition-duration: var(--self-transition-duration); + transition-timing-function: ease-out; + transition-behavior: allow-discrete; + + &::backdrop { + background: rgba(0, 0, 0, 0); + transition: all var(--self-transition-duration) allow-discrete; + } + + &:popover-open::backdrop { + background: rgba(0, 0, 0, 0.2); + + @starting-style { + background: rgba(0, 0, 0, 0); + } + } + } + + /* Transitioning inset-block-start is causing a render crash when using anchor + positioning and transitioning from display: none to display: block. */ + &.hidden .search-container { + visibility: hidden; + } + + &.expanded .search-container { + inset-block-start: 27vh; + max-width: 540px; + } + + .input-container { + anchor-name: --search-input-container; + + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 12px; + background: ${color.container.background}; + + &:hover, &:focus-within { + box-shadow: ${effect.elevation['01']}; + } + } + + input { + flex-grow: 1; + order: 2; + border: none; + padding: 0; + font: inherit; + outline: none; + background: inherit; + } + + .engine-picker-button { + --leo-icon-size: 16px; + + anchor-name: --engine-picker-button; + + order: 1; + padding: 7px; + border-radius: 4px; + border: solid 1px transparent; + + &:hover { + background-color: ${color.container.interactive}; + } + + &.open { + background-color: ${color.container.interactive}; + border-color: ${color.divider.interactive}; + } + } + + .search-button { + --leo-icon-size: 24px; + + order: 3; + padding: 4px; + border-radius: 4px; + visibility: hidden; + opacity: 0; + color: ${color.icon.secondary}; + + transition: opacity var(--self-transition-duration); + + &:hover { + background-color: ${color.container.interactive}; + } + } + + &.expanded .search-button { + visibility: visible; + opacity: 1; + } + + .engine-options { + --leo-icon-size: 20px; + + position: absolute; + position-anchor: --engine-picker-button; + position-area: block-end span-inline-end; + margin-top: 2px; + min-width: 232px; + } + + .results-container { + position: absolute; + position-anchor: --search-input-container; + position-area: bottom center; + + width: anchor-size(width); + margin: 12px 0; + display: flex; + flex-direction: column; + visibility: hidden; + opacity: 0; + + transition: opacity var(--self-transition-duration); + } + + &.expanded .results-container { + visibility: visible; + opacity: 1; + } + +` diff --git a/components/brave_new_tab/resources/components/search/search_box.tsx b/components/brave_new_tab/resources/components/search/search_box.tsx new file mode 100644 index 000000000000..f05c55580569 --- /dev/null +++ b/components/brave_new_tab/resources/components/search/search_box.tsx @@ -0,0 +1,300 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import * as React from 'react' +import Icon from '@brave/leo/react/icon' + +import { + SearchEngineInfo, + SearchResultMatch, + ClickEvent, + defaultSearchEngine, + braveSearchHost } from '../../models/search_model' + +import { useSearchModel, useSearchState } from '../search_context' +import { optional } from '../../lib/optional' +import { urlFromInput } from '../../lib/url_input' +import { useLocale } from '../locale_context' +import { Popover } from '../popover' +import { EngineIcon } from './engine_icon' +import { SearchResults, ResultOption } from './search_results' +import classNames from '$web-common/classnames' + +import { style } from './search_box.style' + +// Returns a list of `ResultOptions` for the specified query and corresponding +// autocomplete matches. In addition to the autocomplete matches, the list may +// also contain a URL match if the user typed in what appears to be a URL. +function getResultOptions(query: string, matches: SearchResultMatch[]) { + const options: ResultOption[] = [] + const inputURL = urlFromInput(query) + if (inputURL) { + let url = inputURL.toString() + const index = url.lastIndexOf(query) + if (index >= 0) { + url = url.substring(0, index + query.length) + } + options.push({ kind: 'url', url }) + } + matches.forEach((match, matchIndex) => { + options.push({ kind: 'match', matchIndex, match }) + }) + return options +} + +interface Props { + onCustomizeSearchEngineList: () => void +} + +export function SearchBox(props: Props) { + const { getString } = useLocale() + const searchModel = useSearchModel() + + const [ + showSearchBox, + searchEngines, + enabledSearchEngines, + lastUsedSearchEngine, + searchMatches + ] = useSearchState((state) => [ + state.showSearchBox, + state.searchEngines, + state.enabledSearchEngines.size > 0 + ? state.enabledSearchEngines + : new Set([defaultSearchEngine]), + state.lastUsedSearchEngine, + state.searchMatches + ]) + + const inputRef = React.useRef(null) + + const [query, setQuery] = React.useState('') + const [expanded, setExpanded] = React.useState(false) + const [selectedOption, setSelectedOption] = React.useState(optional()) + const [showEngineOptions, setShowEngineOptions] = React.useState(false) + const [currentEngine, setCurrentEngine] = + React.useState(lastUsedSearchEngine || defaultSearchEngine) + + // If the enabled search engine list changes, and the current engine is no + // longer in the list, then choose the default search engine. If the default + // search engine is also not in the list, then choose the first engine in the + // list of enabled engines. + React.useEffect(() => { + if (!enabledSearchEngines.has(currentEngine)) { + if (enabledSearchEngines.size === 0 || + enabledSearchEngines.has(defaultSearchEngine)) { + setCurrentEngine(defaultSearchEngine) + } else { + const [firstEngine] = enabledSearchEngines.values() + setCurrentEngine(firstEngine) + } + } + }, [enabledSearchEngines]) + + // Build the list of result options. The result options can contain a direct + // URL (if the user has typed a URL) or a list of autocomplete options. + const resultOptions = React.useMemo( + () => getResultOptions(query, searchMatches), + [query, searchMatches]) + + // When the result option list changes, select the first available option that + // is allowed to be the default match. + React.useEffect(() => { + const optionSelected = resultOptions.some((option, index) => { + if (option.kind === 'url' || option.match.allowedToBeDefaultMatch) { + setSelectedOption(optional(index)) + return true + } + return false + }) + if (!optionSelected) { + setSelectedOption(optional()) + } + }, [resultOptions]) + + const searchEngine = + searchEngines.find(({ host }) => host === currentEngine) + + function updateQuery(query: string) { + setQuery(query) + if (query) { + searchModel.queryAutocomplete(query, currentEngine) + } else { + searchModel.stopAutocomplete() + } + } + + function getPlaceholder() { + if (currentEngine === braveSearchHost) { + return getString('searchBoxPlaceholderTextBrave') + } + return getString('searchBoxPlaceholderText') + } + + function focusInput() { + inputRef.current?.focus() + } + + function onSelectSearchEngine(engine: SearchEngineInfo) { + return () => { + setCurrentEngine(engine.host) + searchModel.setLastUsedSearchEngine(engine.host) + searchModel.stopAutocomplete() + if (query) { + searchModel.queryAutocomplete(query, engine.host) + } + setShowEngineOptions(false) + focusInput() + } + } + + function onSearchClick(event: React.MouseEvent) { + if (query) { + searchModel.openSearch(query, currentEngine, event) + } + } + + function updateSelectedOption(step: number) { + if (resultOptions.length === 0) { + setSelectedOption(optional()) + return + } + let index = selectedOption.valueOr(-1) + step + if (!selectedOption.hasValue() && step <= 0) { + index += 1 + } + if (index < 0) { + index = resultOptions.length - 1 + } else if (index >= resultOptions.length) { + index = 0 + } + setSelectedOption(optional(index)) + } + + function onOptionClick(option: ResultOption, event: ClickEvent) { + switch (option.kind) { + case 'url': { + searchModel.openUrlFromSearch(option.url, event) + break + } + case 'match': { + searchModel.openAutocompleteMatch(option.matchIndex, event) + break + } + } + } + + function onSearchSuggestionsEnabled() { + updateQuery(query) + focusInput() + } + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Enter') { + if (selectedOption.hasValue()) { + const option = resultOptions[selectedOption.value()] + onOptionClick(option, { ...event, button: 0 }) + } else if (query) { + searchModel.openSearch(query, currentEngine, { ...event, button: 0 }) + } + event.preventDefault() + } else if (event.key === 'Escape') { + updateQuery('') + } else if (event.key === 'ArrowUp') { + updateSelectedOption(-1) + event.preventDefault() + } else if (event.key === 'ArrowDown') { + updateSelectedOption(1) + event.preventDefault() + } + } + + function onInputContainerClick(event: React.MouseEvent) { + if (event.target === event.currentTarget) { + focusInput() + setExpanded(true) + } + } + + return ( +
+ setExpanded(false)} + > +
+ setExpanded(true)} + onKeyDown={onKeyDown} + onChange={(event) => { + setExpanded(true) + updateQuery(event.target.value) + }} + /> + + +
+
+ +
+ setShowEngineOptions(false)} + > +
+ {searchEngines.map((engine) => { + if (!enabledSearchEngines.has(engine.host)) { + return null + } + return ( + + ) + })} +
+ +
+ + +
+ ) +} diff --git a/components/brave_new_tab/resources/components/search/search_results.style.ts b/components/brave_new_tab/resources/components/search/search_results.style.ts new file mode 100644 index 000000000000..1d733eba98ac --- /dev/null +++ b/components/brave_new_tab/resources/components/search/search_results.style.ts @@ -0,0 +1,120 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { color, effect, font, gradient } from '@brave/leo/tokens/css/variables' +import { scoped } from '../../lib/scoped_css' + +export const style = scoped.css` + + & { + border-radius: 16px; + background: ${color.container.background}; + display: flex; + flex-direction: column; + overflow: clip; + box-shadow: ${effect.elevation['01']}; + } + + .result-options { + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; + } + + button { + --leo-icon-size: 32px; + + border-radius: 8px; + padding: 4px 8px; + display: flex; + align-items: center; + gap: 12px; + color: ${color.text.primary}; + text-decoration: none; + + &:hover, &.selected { + background: ${color.neutral['10']}; + } + } + + .result-image { + flex: 0 0 32px; + min-height: 32px; + display: flex; + align-items: center; + justify-content: center; + } + + img { + width: 32px; + height: 32px; + border-radius: 8px; + + &.icon { + width: 24px; + height: 24px; + opacity: .7; + } + + &.favicon { + width: 20px; + height: 20px; + } + } + + leo-icon { + --leo-icon-size: 24px; + + width: 32px; + height: 32px; + border-radius: 8px; + padding: 4px; + + &.brave-leo-icon { + --leo-icon-color: #fff; + background: ${gradient.iconsActive}; + } + } + + .content { + flex: 1 1 auto; + display: flex; + flex-direction: column; + font: ${font.large.regular}; + } + + .description { + font: ${font.small.regular}; + color: ${color.neutral['30']}; + } + + .suggestions-prompt { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + background: ${color.container.interactive}; + + h4 { + font: ${font.default.semibold}; + } + + p { + font: ${font.small.regular}; + } + + .actions { + display: flex; + align-items: center; + gap: 8px; + + > * { + flex: 0 1 auto; + } + } + } + +` diff --git a/components/brave_new_tab/resources/components/search/search_results.tsx b/components/brave_new_tab/resources/components/search/search_results.tsx new file mode 100644 index 000000000000..2279ffd13ca6 --- /dev/null +++ b/components/brave_new_tab/resources/components/search/search_results.tsx @@ -0,0 +1,144 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import * as React from 'react' +import Button from '@brave/leo/react/button' +import Icon from '@brave/leo/react/icon' + +import { SearchResultMatch, ClickEvent } from '../../models/search_model' +import { useLocale } from '../locale_context' +import { useSearchModel, useSearchState } from '../search_context' +import { placeholderImageSrc } from '../../lib/image_loader' +import { faviconURL } from '../../lib/favicon_url' +import { Optional } from '../../lib/optional' +import { PcdnImage } from '../pcdn_image' + +import { style } from './search_results.style' + +function MatchImage(props: { match: SearchResultMatch }) { + const { getString } = useLocale() + const { imageUrl, iconUrl } = props.match + if (props.match.description === getString('searchAskLeoDescription')) { + return + } + if (!imageUrl) { + return + } + if (imageUrl.startsWith('chrome:')) { + return + } + return +} + +interface URLResultOption { + kind: 'url' + url: string +} + +interface MatchResultOption { + kind: 'match' + matchIndex: number + match: SearchResultMatch +} + +export type ResultOption = URLResultOption | MatchResultOption + +interface Props { + options: ResultOption[] + selectedOption: Optional + onOptionClick: (option: ResultOption, event: ClickEvent) => void + onSearchSuggestionsEnabled: () => void +} + +export function SearchResults(props: Props) { + const { selectedOption, options } = props + + const { getString } = useLocale() + const searchModel = useSearchModel() + + const [ + searchSuggestionsEnabled, + searchSuggestionsPromptDismissed + ] = useSearchState((state) => [ + state.searchSuggestionsEnabled, + state.searchSuggestionsPromptDismissed + ]) + + if (options.length === 0) { + return null + } + + return ( +
+ { + !searchSuggestionsEnabled && !searchSuggestionsPromptDismissed && +
+

{getString('searchSuggestionsPromptTitle')}

+

+ {getString('searchSuggestionsPromptText')} +

+
+ + +
+
+ } +
+ {options.map((option, index) => { + const isSelected = selectedOption.valueOr(-1) === index + const className = isSelected ? 'selected' : '' + const onClick = (event: React.MouseEvent) => { + props.onOptionClick(option, event) + } + + if (option.kind === 'url') { + return ( + + ) + } + + const { match } = option + + return ( + + ) + })} +
+
+ ) +} diff --git a/components/brave_new_tab/resources/components/search_context.tsx b/components/brave_new_tab/resources/components/search_context.tsx new file mode 100644 index 000000000000..b00ba06681ad --- /dev/null +++ b/components/brave_new_tab/resources/components/search_context.tsx @@ -0,0 +1,32 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import * as React from 'react' + +import { SearchModel, SearchState, defaultModel } from '../models/search_model' +import { useModelState } from '../lib/use_model_state' + +const Context = React.createContext(defaultModel()) + +interface Props { + model: SearchModel + children: React.ReactNode +} + +export function SearchContext(props: Props) { + return ( + + {props.children} + + ) +} + +export function useSearchModel(): SearchModel { + return React.useContext(Context) +} + +export function useSearchState(map: (state: SearchState) => T): T { + return useModelState(useSearchModel(), map) +} diff --git a/components/brave_new_tab/resources/components/settings/search_panel.style.ts b/components/brave_new_tab/resources/components/settings/search_panel.style.ts new file mode 100644 index 000000000000..a05f69a19ba9 --- /dev/null +++ b/components/brave_new_tab/resources/components/settings/search_panel.style.ts @@ -0,0 +1,62 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { color, font } from '@brave/leo/tokens/css/variables' +import { scoped } from '../../lib/scoped_css' + +export const style = scoped.css` + & { + display: flex; + flex-direction: column; + gap: 16px; + } + + .toggle-row { + display: flex; + align-items: center; + + label { + flex: 1 1 auto; + } + } + + .search-engines { + --leo-checkbox-flex-direction: row-reverse; + --leo-checkbox-label-gap: 16px; + --leo-icon-size: 20px; + + display: flex; + flex-direction: column; + gap: 16px; + } + + .engine-name { + flex: 1 1 auto; + } + + .engine-icon { + width: 20px; + height: 20px; + } + + h4 { + font: ${font.default.semibold}; + } + + .divider { + height: 1px; + background: ${color.divider.subtle}; + } + + .customize-link { + --leo-icon-size: 20px; + + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: ${color.text.primary}; + } +` diff --git a/components/brave_new_tab/resources/components/settings/search_panel.tsx b/components/brave_new_tab/resources/components/settings/search_panel.tsx new file mode 100644 index 000000000000..0de7ab66d7aa --- /dev/null +++ b/components/brave_new_tab/resources/components/settings/search_panel.tsx @@ -0,0 +1,78 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import * as React from 'react' +import Checkbox from '@brave/leo/react/checkbox' +import Icon from '@brave/leo/react/icon' +import Toggle from '@brave/leo/react/toggle' + +import { useSearchModel, useSearchState } from '../search_context' +import { useLocale } from '../locale_context' +import { EngineIcon } from '../search/engine_icon' + +import { style } from './search_panel.style' + +export function SearchPanel() { + const { getString } = useLocale() + const model = useSearchModel() + + const [ + showSearchBox, + searchEngines, + enabledSearchEngines + ] = useSearchState((state) => [ + state.showSearchBox, + state.searchEngines, + state.enabledSearchEngines + ]) + + return ( +
+
+ + { model.setShowSearchBox(checked) }} + /> +
+ { + showSearchBox && <> +

{getString('enabledSearchEnginesLabel')}

+
+
+ { + searchEngines.map((engine) => { + return ( + { + model.setSearchEngineEnabled(engine.host, checked) + }} + > + {engine.name} + + + ) + }) + } +
+ + ) +} diff --git a/components/brave_new_tab/resources/components/settings/settings_modal.tsx b/components/brave_new_tab/resources/components/settings/settings_modal.tsx index 228125b5d248..151b154eb8a1 100644 --- a/components/brave_new_tab/resources/components/settings/settings_modal.tsx +++ b/components/brave_new_tab/resources/components/settings/settings_modal.tsx @@ -9,11 +9,12 @@ import Navigation from '@brave/leo/react/navigation' import NavigationItem from '@brave/leo/react/navigationItem' import { BackgroundPanel } from './background_panel' +import { SearchPanel } from './search_panel' import { useLocale } from '../locale_context' import { style } from './settings_modal.style' -export type SettingsView = 'background' +export type SettingsView = 'background' | 'search' interface Props { initialView: SettingsView | null @@ -36,12 +37,14 @@ export function SettingsModal(props: Props) { function renderPanel() { switch (currentView) { case 'background': return + case 'search': return } } function getNavItemText(view: SettingsView) { switch (view) { case 'background': return getString('backgroundSettingsTitle') + case 'search': return getString('searchSettingsTitle') } } @@ -66,6 +69,7 @@ export function SettingsModal(props: Props) {
diff --git a/components/brave_new_tab/resources/lib/favicon_url.ts b/components/brave_new_tab/resources/lib/favicon_url.ts new file mode 100644 index 000000000000..f59ccac76098 --- /dev/null +++ b/components/brave_new_tab/resources/lib/favicon_url.ts @@ -0,0 +1,8 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +export function faviconURL(url: string) { + return 'chrome://favicon2/?size=64&pageUrl=' + encodeURIComponent(url) +} diff --git a/components/brave_new_tab/resources/lib/locale_strings.ts b/components/brave_new_tab/resources/lib/locale_strings.ts index 2e7c6ddbd8bb..27ac120cc693 100644 --- a/components/brave_new_tab/resources/lib/locale_strings.ts +++ b/components/brave_new_tab/resources/lib/locale_strings.ts @@ -8,12 +8,24 @@ export type StringKey = 'braveBackgroundLabel' | 'customBackgroundLabel' | 'customBackgroundTitle' | + 'customizeSearchEnginesLink' | + 'enabledSearchEnginesLabel' | 'gradientBackgroundLabel' | 'gradientBackgroundTitle' | 'photoCreditsText' | 'randomizeBackgroundLabel' | + 'searchAskLeoDescription' | + 'searchBoxPlaceholderText' | + 'searchBoxPlaceholderTextBrave' | + 'searchCustomizeEngineListText' | + 'searchSettingsTitle' | + 'searchSuggestionsDismissButtonLabel' | + 'searchSuggestionsEnableButtonLabel' | + 'searchSuggestionsPromptText' | + 'searchSuggestionsPromptTitle' | 'settingsTitle' | 'showBackgroundsLabel' | + 'showSearchBoxLabel' | 'showSponsoredImagesLabel' | 'solidBackgroundLabel' | 'solidBackgroundTitle' | diff --git a/components/brave_new_tab/resources/lib/optional.ts b/components/brave_new_tab/resources/lib/optional.ts new file mode 100644 index 000000000000..17d5fbfb6d41 --- /dev/null +++ b/components/brave_new_tab/resources/lib/optional.ts @@ -0,0 +1,31 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +export class Optional { + value_: T | undefined + + constructor (value?: T) { + this.value_ = value + } + + hasValue () { + return this.value_ !== undefined + } + + value () { + if (this.value_ === undefined) { + throw new Error('Cannot get value of empty optional') + } + return this.value_ + } + + valueOr (fallback: U) { + return this.value_ === undefined ? fallback : this.value_ + } +} + +export function optional (value?: T) { + return new Optional(value) +} diff --git a/components/brave_new_tab/resources/lib/url_input.ts b/components/brave_new_tab/resources/lib/url_input.ts new file mode 100644 index 000000000000..38454c9efa03 --- /dev/null +++ b/components/brave_new_tab/resources/lib/url_input.ts @@ -0,0 +1,27 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +export function urlFromInput(input: string) { + if (/\s/.test(input)) { + return null + } + const bits = input.split('.') + if (bits.length <= 1 || bits.join('').length === 0) { + return null + } + if (!input.includes('://')) { + input = `https://${input}` + } + const schemes = new Set(['http:', 'https:']) + try { + const url = new URL(input) + if (!schemes.has(url.protocol)) { + return null + } + return url + } catch { + return null + } +} diff --git a/components/brave_new_tab/resources/models/new_tab_model.ts b/components/brave_new_tab/resources/models/new_tab_model.ts index ab0d4d56e4b5..859acbcea23c 100644 --- a/components/brave_new_tab/resources/models/new_tab_model.ts +++ b/components/brave_new_tab/resources/models/new_tab_model.ts @@ -71,6 +71,7 @@ export function defaultState(): NewTabState { export interface NewTabModel { getState: () => NewTabState addListener: (listener: (state: NewTabState) => void) => () => void + getPcdnImageURL: (url: string) => Promise setBackgroundsEnabled: (enabled: boolean) => void setSponsoredImagesEnabled: (enabled: boolean) => void selectBackground: (type: BackgroundType, value: string) => void @@ -83,6 +84,7 @@ export function defaultModel(): NewTabModel { return { getState() { return state }, addListener() { return () => {} }, + async getPcdnImageURL(url) { return url }, setBackgroundsEnabled(enabled) {}, setSponsoredImagesEnabled(enabled) {}, selectBackground(type, value) {}, diff --git a/components/brave_new_tab/resources/models/search_model.ts b/components/brave_new_tab/resources/models/search_model.ts new file mode 100644 index 000000000000..82333d9d794f --- /dev/null +++ b/components/brave_new_tab/resources/models/search_model.ts @@ -0,0 +1,88 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +export const braveSearchHost = 'search.brave.com' + +export const defaultSearchEngine = braveSearchHost + +export interface SearchEngineInfo { + prepopulateId: bigint + name: string + keyword: string + host: string + faviconUrl: string +} + +export interface SearchResultMatch { + allowedToBeDefaultMatch: boolean + contents: string + description: string + iconUrl: string + imageUrl: string + destinationUrl: string +} + +export interface SearchState { + showSearchBox: boolean + searchEngines: SearchEngineInfo[] + enabledSearchEngines: Set + lastUsedSearchEngine: string + searchSuggestionsEnabled: boolean + searchSuggestionsPromptDismissed: boolean + searchMatches: SearchResultMatch[] +} + +export function defaultState(): SearchState { + return { + showSearchBox: false, + searchEngines: [], + enabledSearchEngines: new Set(), + lastUsedSearchEngine: '', + searchSuggestionsEnabled: true, + searchSuggestionsPromptDismissed: false, + searchMatches: [] + } +} + +export interface ClickEvent { + button: number + altKey: boolean + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean +} + +export interface SearchModel { + getState: () => SearchState + addListener: (listener: (state: SearchState) => void) => () => void + setShowSearchBox: (showSearchBox: boolean) => void + setSearchSuggestionsEnabled: (enabled: boolean) => void + setSearchSuggestionsPromptDismissed: (dismissed: boolean) => void + setLastUsedSearchEngine: (engine: string) => void + setSearchEngineEnabled: (engine: string, enabled: boolean) => void + queryAutocomplete: (query: string, engine: string) => void + openAutocompleteMatch: (index: number, event: ClickEvent) => void + stopAutocomplete: () => void + openSearch: (query: string, engine: string, event: ClickEvent) => void + openUrlFromSearch: (url: string, event: ClickEvent) => void +} + +export function defaultModel(): SearchModel { + const state = defaultState() + return { + getState() { return state }, + addListener() { return () => {} }, + setShowSearchBox(showSearchBox) {}, + setSearchSuggestionsEnabled(enabled) {}, + setSearchSuggestionsPromptDismissed(dismissed) {}, + setSearchEngineEnabled(engine, enabled) {}, + setLastUsedSearchEngine(engine) {}, + queryAutocomplete(query, engine) {}, + openAutocompleteMatch(index, event) {}, + stopAutocomplete() {}, + openSearch(query, engine, event) {}, + openUrlFromSearch(url, event) {} + } +} diff --git a/components/brave_new_tab/resources/new_tab_page.tsx b/components/brave_new_tab/resources/new_tab_page.tsx index e5fc61b5b046..658a1341aad5 100644 --- a/components/brave_new_tab/resources/new_tab_page.tsx +++ b/components/brave_new_tab/resources/new_tab_page.tsx @@ -10,23 +10,29 @@ import { setIconBasePath } from '@brave/leo/react/icon' import { LocaleContext } from './components/locale_context' import { NewTabContext } from './components/new_tab_context' import { createNewTabModel } from './webui/webui_new_tab_model' +import { SearchContext } from './components/search_context' +import { createSearchModel } from './webui/webui_search_model' import { createLocale } from './webui/webui_locale' import { App } from './components/app' setIconBasePath('chrome://resources/brave-icons') const newTabModel = createNewTabModel() +const searchModel = createSearchModel() Object.assign(self, { [Symbol.for('ntpInternals')]: { - newTabModel + newTabModel, + searchModel } }) createRoot(document.getElementById('root')!).render( - + + + ) diff --git a/components/brave_new_tab/resources/stories/index.tsx b/components/brave_new_tab/resources/stories/index.tsx index ade09044f1f4..f3890cc86b44 100644 --- a/components/brave_new_tab/resources/stories/index.tsx +++ b/components/brave_new_tab/resources/stories/index.tsx @@ -7,7 +7,9 @@ import * as React from 'react' import { LocaleContext } from '../components/locale_context' import { NewTabContext } from '../components/new_tab_context' +import { SearchContext } from '../components/search_context' import { createNewTabModel } from './sb_new_tab_model' +import { createSearchModel } from './sb_search_model' import { createLocale } from './sb_locale' import { App } from '../components/app' @@ -20,9 +22,11 @@ export function NTPNext() { return ( -
- -
+ +
+ +
+
) diff --git a/components/brave_new_tab/resources/stories/sb_locale.ts b/components/brave_new_tab/resources/stories/sb_locale.ts index 3323aeb07e29..7606a7fd76b9 100644 --- a/components/brave_new_tab/resources/stories/sb_locale.ts +++ b/components/brave_new_tab/resources/stories/sb_locale.ts @@ -10,12 +10,24 @@ const localeStrings: { [K in StringKey]: string } = { braveBackgroundLabel: 'Brave backgrounds', customBackgroundLabel: 'Use your own', customBackgroundTitle: 'Use your own', + customizeSearchEnginesLink: 'Customize available engines', + enabledSearchEnginesLabel: 'Enabled search engines', gradientBackgroundLabel: 'Gradients', gradientBackgroundTitle: 'Gradients', photoCreditsText: 'Photo by $1', randomizeBackgroundLabel: 'Refresh on every new tab', - settingsTitle: 'Customize Dashboard', + searchAskLeoDescription: 'Ask Leo', + searchBoxPlaceholderText: 'Search the web', + searchBoxPlaceholderTextBrave: 'Ask Brave Search', + searchCustomizeEngineListText: 'Customize list', + searchSettingsTitle: 'Search', + searchSuggestionsDismissButtonLabel: 'No thanks', + searchSuggestionsEnableButtonLabel: 'Enable', + searchSuggestionsPromptText: 'When you search, what you type will be sent to your search engine for better suggestions.', + searchSuggestionsPromptTitle: 'Enable search suggestions?', + settingsTitle: 'Customize', showBackgroundsLabel: 'Show background images', + showSearchBoxLabel: 'Show search widget in new tabs', showSponsoredImagesLabel: 'Show Sponsored Images', solidBackgroundLabel: 'Solid colors', solidBackgroundTitle: 'Solid colors', diff --git a/components/brave_new_tab/resources/stories/sb_search_model.ts b/components/brave_new_tab/resources/stories/sb_search_model.ts new file mode 100644 index 000000000000..6f9ee86e1651 --- /dev/null +++ b/components/brave_new_tab/resources/stories/sb_search_model.ts @@ -0,0 +1,100 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { createStore } from '../lib/store' + +import { SearchModel, defaultModel, defaultState } from '../models/search_model' + +export function createSearchModel(): SearchModel { + const store = createStore({ + ...defaultState(), + + showSearchBox: true, + + searchSuggestionsEnabled: false, + + searchSuggestionsPromptDismissed: false, + + searchEngines: [{ + prepopulateId: BigInt(0), + name: 'Brave', + keyword: '', + host: 'search.brave.com', + faviconUrl: '' + }, { + prepopulateId: BigInt(1), + name: 'Google', + keyword: '', + host: 'google.com', + faviconUrl: '' + }], + + enabledSearchEngines: new Set([ + 'search.brave.com', + 'google.com' + ]) + }) + + return { + ...defaultModel(), + + getState: store.getState, + + addListener: store.addListener, + + setShowSearchBox(showSearchBox) { + store.update({ showSearchBox }) + }, + + setSearchSuggestionsEnabled(enabled) { + store.update({ searchSuggestionsEnabled: enabled }) + }, + + setSearchSuggestionsPromptDismissed(dismissed) { + store.update({ searchSuggestionsPromptDismissed: dismissed }) + }, + + setLastUsedSearchEngine(engine) { + store.update({ lastUsedSearchEngine: engine }) + }, + + setSearchEngineEnabled(engine, enabled) { + store.update(({ enabledSearchEngines }) => { + enabledSearchEngines = new Set(enabledSearchEngines) + if (enabled) { + enabledSearchEngines.add(engine) + } else if (enabledSearchEngines.size > 1) { + enabledSearchEngines.delete(engine) + } + return { enabledSearchEngines } + }) + }, + + queryAutocomplete(query, engine) { + store.update({ + searchMatches: [{ + allowedToBeDefaultMatch: false, + contents: 'contents 1', + description: 'description 1', + iconUrl: '', + imageUrl: '', + destinationUrl: '' + }, + { + allowedToBeDefaultMatch: true, + contents: 'contents 2', + description: 'Ask Leo', + iconUrl: '', + imageUrl: '', + destinationUrl: '' + }] + }) + }, + + stopAutocomplete() { + store.update({ searchMatches: [] }) + } + } +} diff --git a/components/brave_new_tab/resources/webui/new_tab_page_proxy.ts b/components/brave_new_tab/resources/webui/new_tab_page_proxy.ts index 2260042f6595..e44c0899c1d7 100644 --- a/components/brave_new_tab/resources/webui/new_tab_page_proxy.ts +++ b/components/brave_new_tab/resources/webui/new_tab_page_proxy.ts @@ -20,7 +20,7 @@ export class NewTabPageProxy { } addListeners(listeners: Partial) { - addCallbackListeners(this.callbackRouter, listeners) + return addCallbackListeners(this.callbackRouter, listeners) } static getInstance(): NewTabPageProxy { diff --git a/components/brave_new_tab/resources/webui/search_box_proxy.ts b/components/brave_new_tab/resources/webui/search_box_proxy.ts new file mode 100644 index 000000000000..65ecfdc8f12b --- /dev/null +++ b/components/brave_new_tab/resources/webui/search_box_proxy.ts @@ -0,0 +1,35 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import * as mojom from 'gen/ui/webui/resources/cr_components/searchbox/searchbox.mojom.m' + +import { addCallbackListeners } from './callback_listeners' + +let instance: SearchBoxProxy | null = null + +export class SearchBoxProxy { + callbackRouter: mojom.PageCallbackRouter + handler: mojom.PageHandlerRemote + + constructor(callbackRouter: mojom.PageCallbackRouter, + handler: mojom.PageHandlerRemote) { + this.callbackRouter = callbackRouter + this.handler = handler + } + + addListeners(listeners: Partial) { + return addCallbackListeners(this.callbackRouter, listeners) + } + + static getInstance(): SearchBoxProxy { + if (!instance) { + const callbackRouter = new mojom.PageCallbackRouter() + const handler = mojom.PageHandler.getRemote() + handler.setPage(callbackRouter.$.bindNewPipeAndPassRemote()) + instance = new SearchBoxProxy(callbackRouter, handler) + } + return instance + } +} diff --git a/components/brave_new_tab/resources/webui/webui_new_tab_model.ts b/components/brave_new_tab/resources/webui/webui_new_tab_model.ts index 7ce2c8773415..70d94bff948c 100644 --- a/components/brave_new_tab/resources/webui/webui_new_tab_model.ts +++ b/components/brave_new_tab/resources/webui/webui_new_tab_model.ts @@ -35,6 +35,7 @@ export function createNewTabModel(): NewTabModel { const newTabProxy = NewTabPageProxy.getInstance() const { handler } = newTabProxy const store = createStore(defaultState()) + const pcdnImageURLs = new Map() function updateCurrentBackground() { store.update({ @@ -123,6 +124,21 @@ export function createNewTabModel(): NewTabModel { addListener: store.addListener, + async getPcdnImageURL(url) { + const cachedURL = pcdnImageURLs.get(url) + if (cachedURL) { + return cachedURL + } + const { resourceData } = await handler.loadResourceFromPcdn(url) + if (!resourceData) { + throw new Error('Image resource could not be loaded from PCDN') + } + const blob = new Blob([new Uint8Array(resourceData)], { type: 'image/*' }) + const objectURL = URL.createObjectURL(blob) + pcdnImageURLs.set(url, objectURL) + return objectURL + }, + setBackgroundsEnabled(enabled) { store.update({ backgroundsEnabled: enabled }) handler.setBackgroundsEnabled(enabled) diff --git a/components/brave_new_tab/resources/webui/webui_search_model.ts b/components/brave_new_tab/resources/webui/webui_search_model.ts new file mode 100644 index 000000000000..7a0746c66a97 --- /dev/null +++ b/components/brave_new_tab/resources/webui/webui_search_model.ts @@ -0,0 +1,206 @@ +/* Copyright (c) 2024 The Brave Authors. All rights reserved. + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import { stringToMojoString16, mojoString16ToString } from 'chrome://resources/js/mojo_type_util.js' + +import { SearchBoxProxy } from './search_box_proxy' +import { NewTabPageProxy } from './new_tab_page_proxy' +import { createStore } from '../lib/store' + +import { + SearchModel, + SearchEngineInfo, + defaultSearchEngine, + defaultState } from '../models/search_model' + +const enabledSearchEnginesStorageKey = 'search-engines' + +function loadEnabledSearchEngines(availableEngines: SearchEngineInfo[]) { + const set = new Set([defaultSearchEngine]) + const data = localStorage.getItem(enabledSearchEnginesStorageKey) + if (!data) { + return set + } + let record: any = null + try { + record = JSON.parse(data) + } catch {} + if (!record || typeof record !== 'object') { + return set + } + set.clear() + for (const engine of availableEngines) { + if (record[engine.host]) { + set.add(engine.host) + } + } + if (set.size === 0) { + set.add(defaultSearchEngine) + } + return set +} + +function storeEnabledSearchEngines(engines: Set) { + let record: Record = {} + for (const engine of engines) { + record[engine] = true + } + localStorage.setItem(enabledSearchEnginesStorageKey, JSON.stringify(record)) +} + +export function createSearchModel(): SearchModel { + const searchProxy = SearchBoxProxy.getInstance() + const newTabProxy = NewTabPageProxy.getInstance() + const store = createStore(defaultState()) + + async function updateSearchEngines() { + const { searchEngines } = + await newTabProxy.handler.getAvailableSearchEngines() + + store.update({ + searchEngines, + enabledSearchEngines: loadEnabledSearchEngines(searchEngines) + }) + } + + async function updatePrefs() { + const [ + { showSearchBox }, + { enabled: searchSuggestionsEnabled }, + { dismissed: searchSuggestionsPromptDismissed }, + { engine: lastUsedSearchEngine } + ] = await Promise.all([ + newTabProxy.handler.getShowSearchBox(), + newTabProxy.handler.getSearchSuggestionsEnabled(), + newTabProxy.handler.getSearchSuggestionsPromptDismissed(), + newTabProxy.handler.getLastUsedSearchEngine() + ]) + store.update({ + showSearchBox, + searchSuggestionsEnabled, + searchSuggestionsPromptDismissed, + lastUsedSearchEngine + }) + } + + searchProxy.addListeners({ + autocompleteResultChanged(result) { + const searchMatches = result.matches.map((m) => { + const match = { + allowedToBeDefaultMatch: m.allowedToBeDefaultMatch, + contents: mojoString16ToString(m.contents), + description: mojoString16ToString(m.description), + iconUrl: m.iconUrl, + imageUrl: m.imageUrl, + destinationUrl: m.destinationUrl.url + } + + if (m.swapContentsAndDescription) { + const { contents } = match + match.contents = match.description + match.description = contents + } + + return match + }) + store.update({ searchMatches }) + } + }) + + newTabProxy.addListeners({ + onSearchPrefsUpdated() { + updatePrefs() + } + }) + + async function loadData() { + await Promise.all([ + updateSearchEngines(), + updatePrefs() + ]) + } + + loadData() + + return { + getState: store.getState, + + addListener: store.addListener, + + setShowSearchBox(showSearchBox) { + store.update({ showSearchBox }) + newTabProxy.handler.setShowSearchBox(showSearchBox) + }, + + setSearchSuggestionsEnabled(enabled) { + store.update({ searchSuggestionsEnabled: enabled }) + newTabProxy.handler.setSearchSuggestionsEnabled(enabled) + }, + + setSearchSuggestionsPromptDismissed(dismissed) { + store.update({ searchSuggestionsPromptDismissed: dismissed }) + newTabProxy.handler.setSearchSuggestionsPromptDismissed(dismissed) + }, + + setSearchEngineEnabled(engine, enabled) { + store.update(({ enabledSearchEngines }) => { + // Copy the set to ensure component state is updated. + enabledSearchEngines = new Set(enabledSearchEngines) + if (enabled) { + enabledSearchEngines.add(engine) + } else if (enabledSearchEngines.size > 1) { + enabledSearchEngines.delete(engine) + } + storeEnabledSearchEngines(enabledSearchEngines) + return { enabledSearchEngines } + }) + }, + + setLastUsedSearchEngine(engine) { + store.update({ lastUsedSearchEngine: engine }) + newTabProxy.handler.setLastUsedSearchEngine(engine) + }, + + queryAutocomplete(query, engine) { + const { searchEngines } = store.getState() + const searchEngine = searchEngines.find(({ host }) => host === engine) + if (searchEngine && searchEngine.keyword) { + query = [searchEngine.keyword, query].join(' ') + } + searchProxy.handler.queryAutocomplete(stringToMojoString16(query), false) + }, + + openAutocompleteMatch(index, event) { + if (index < 0) { + return + } + const match = store.getState().searchMatches.at(index) + if (!match) { + return + } + searchProxy.handler.openAutocompleteMatch( + index, + { url: match.destinationUrl }, + true, + event.button, + event.altKey, + event.ctrlKey, + event.metaKey, + event.shiftKey) + }, + + stopAutocomplete() { + searchProxy.handler.stopAutocomplete(true) + }, + + openSearch(query, engine, event) { + newTabProxy.handler.openSearch(query, engine, event) + }, + + openUrlFromSearch(url, event) { + newTabProxy.handler.openURLFromSearch(url, event) + } + } +}