diff --git a/.storybook/main.ts b/.storybook/main.ts index c201a222ea00..1d022664c8e9 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -22,7 +22,11 @@ const slashStoriesIndexer: Indexer = { const config: StorybookConfig = { stories: process.env.STORYBOOK_STORYPATH ? [`../${process.env.STORYBOOK_STORYPATH}`] - : ['../components/**/stories/*.tsx', '../components/**/*.stories.tsx'], + : [ + '../components/**/stories/*.tsx', + '../components/**/*.stories.tsx', + '../browser/resources/**/stories/*.tsx' + ], typescript: { check: false, reactDocgen: false, diff --git a/browser/about_flags.cc b/browser/about_flags.cc index 06e4dff3521c..c74a47a6517d 100644 --- a/browser/about_flags.cc +++ b/browser/about_flags.cc @@ -535,6 +535,13 @@ const flags_ui::FeatureEntry::FeatureVariation kZCashFeatureVariations[] = { kOsDesktop, \ FEATURE_VALUE_TYPE(features::kBraveNtpSearchWidget), \ }, \ + { \ + "brave-use-updated-ntp", \ + "Use the updated New Tab Page", \ + "Uses an updated version of the New Tab Page", \ + kOsDesktop, \ + FEATURE_VALUE_TYPE(features::kUseUpdatedNTP), \ + }, \ { \ "brave-adblock-cname-uncloaking", \ "Enable CNAME uncloaking", \ diff --git a/browser/brave_browser_features.cc b/browser/brave_browser_features.cc index 85c244faad08..8e3ca0b1dd30 100644 --- a/browser/brave_browser_features.cc +++ b/browser/brave_browser_features.cc @@ -9,6 +9,10 @@ namespace features { +BASE_FEATURE(kUseUpdatedNTP, + "BraveUseUpdatedNewTabPage", + base::FEATURE_DISABLED_BY_DEFAULT); + // Cleanup Session Cookies on browser restart if Session Restore is enabled. BASE_FEATURE(kBraveCleanupSessionCookiesOnSessionRestore, "BraveCleanupSessionCookiesOnSessionRestore", diff --git a/browser/brave_browser_features.h b/browser/brave_browser_features.h index ad30895a251e..5d06cd360b4f 100644 --- a/browser/brave_browser_features.h +++ b/browser/brave_browser_features.h @@ -13,6 +13,7 @@ namespace features { +BASE_DECLARE_FEATURE(kUseUpdatedNTP); BASE_DECLARE_FEATURE(kBraveCleanupSessionCookiesOnSessionRestore); BASE_DECLARE_FEATURE(kBraveCopyCleanLinkByDefault); BASE_DECLARE_FEATURE(kBraveCopyCleanLinkFromJs); diff --git a/browser/brave_content_browser_client.cc b/browser/brave_content_browser_client.cc index 16b792f78cf9..4b1895d04487 100644 --- a/browser/brave_content_browser_client.cc +++ b/browser/brave_content_browser_client.cc @@ -233,6 +233,7 @@ using extensions::ChromeContentBrowserClientExtensionsPart; #if !BUILDFLAG(IS_ANDROID) #include "brave/browser/new_tab/new_tab_shows_navigation_throttle.h" #include "brave/browser/ui/geolocation/brave_geolocation_permission_tab_helper.h" +#include "brave/browser/ui/webui/brave_new_tab/new_tab_page_ui.h" #include "brave/browser/ui/webui/brave_news_internals/brave_news_internals_ui.h" #include "brave/browser/ui/webui/brave_rewards/rewards_page_top_ui.h" #include "brave/browser/ui/webui/brave_rewards/rewards_panel_ui.h" @@ -664,14 +665,21 @@ void BraveContentBrowserClient::RegisterWebUIInterfaceBrokers( .Add() .Add(); + auto ntp_next_registration = + registry.ForWebUI() + .Add() + .Add(); + #if BUILDFLAG(ENABLE_BRAVE_VPN) if (brave_vpn::IsBraveVPNFeatureEnabled()) { ntp_registration.Add(); + ntp_next_registration.Add(); } #endif if (base::FeatureList::IsEnabled(features::kBraveNtpSearchWidget)) { ntp_registration.Add(); + ntp_next_registration.Add(); } if (base::FeatureList::IsEnabled( diff --git a/browser/resources/brave_new_tab/BUILD.gn b/browser/resources/brave_new_tab/BUILD.gn new file mode 100644 index 000000000000..673a56a7e80e --- /dev/null +++ b/browser/resources/brave_new_tab/BUILD.gn @@ -0,0 +1,29 @@ +# Copyright (c) 2025 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("//brave/components/common/typescript.gni") +import("//mojo/public/tools/bindings/mojom.gni") + +assert(!is_android) + +transpile_web_ui("resources") { + entry_points = [ [ + "new_tab", + rebase_path("new_tab_page.tsx"), + ] ] + resource_name = "brave_new_tab" + output_module = true + deps = [ + "//brave/browser/ui/webui/brave_new_tab:mojom_js", + "//brave/components/brave_rewards/core/mojom:webui_js", + "//brave/components/brave_vpn/common/mojom:mojom_js", + ] +} + +pack_web_resources("generated_resources") { + resource_name = "brave_new_tab" + output_dir = "$root_gen_dir/brave/browser/resources/brave_new_tab" + deps = [ ":resources" ] +} diff --git a/browser/resources/brave_new_tab/assets/favorites_active.svg b/browser/resources/brave_new_tab/assets/favorites_active.svg new file mode 100644 index 000000000000..52232b6c9cd2 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/favorites_active.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/favorites_active_dark.svg b/browser/resources/brave_new_tab/assets/favorites_active_dark.svg new file mode 100644 index 000000000000..9a7d067763a5 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/favorites_active_dark.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/favorites_inactive.svg b/browser/resources/brave_new_tab/assets/favorites_inactive.svg new file mode 100644 index 000000000000..b8013a6b7fb9 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/favorites_inactive.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/favorites_inactive_dark.svg b/browser/resources/brave_new_tab/assets/favorites_inactive_dark.svg new file mode 100644 index 000000000000..1b97ecad6758 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/favorites_inactive_dark.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/frequently_visited_active.svg b/browser/resources/brave_new_tab/assets/frequently_visited_active.svg new file mode 100644 index 000000000000..1390115434f0 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/frequently_visited_active.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/frequently_visited_active_dark.svg b/browser/resources/brave_new_tab/assets/frequently_visited_active_dark.svg new file mode 100644 index 000000000000..ee6a933aba6a --- /dev/null +++ b/browser/resources/brave_new_tab/assets/frequently_visited_active_dark.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/frequently_visited_inactive.svg b/browser/resources/brave_new_tab/assets/frequently_visited_inactive.svg new file mode 100644 index 000000000000..8f7c89a505e5 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/frequently_visited_inactive.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/frequently_visited_inactive_dark.svg b/browser/resources/brave_new_tab/assets/frequently_visited_inactive_dark.svg new file mode 100644 index 000000000000..8b64a2d17311 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/frequently_visited_inactive_dark.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/guardian_vpn_logo.svg b/browser/resources/brave_new_tab/assets/guardian_vpn_logo.svg new file mode 100644 index 000000000000..0558e74de79a --- /dev/null +++ b/browser/resources/brave_new_tab/assets/guardian_vpn_logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/browser/resources/brave_new_tab/assets/rewards_bat_coin.svg b/browser/resources/brave_new_tab/assets/rewards_bat_coin.svg new file mode 100644 index 000000000000..fc7e80963f8b --- /dev/null +++ b/browser/resources/brave_new_tab/assets/rewards_bat_coin.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/talk_graphic.svg b/browser/resources/brave_new_tab/assets/talk_graphic.svg new file mode 100644 index 000000000000..d8ce336182f8 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/talk_graphic.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/vpn_shield_connected.svg b/browser/resources/brave_new_tab/assets/vpn_shield_connected.svg new file mode 100644 index 000000000000..80a553b7d6f4 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/vpn_shield_connected.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/browser/resources/brave_new_tab/assets/vpn_shield_disconnected.svg b/browser/resources/brave_new_tab/assets/vpn_shield_disconnected.svg new file mode 100644 index 000000000000..749ceed5d497 --- /dev/null +++ b/browser/resources/brave_new_tab/assets/vpn_shield_disconnected.svg @@ -0,0 +1,4 @@ + + + + diff --git a/browser/resources/brave_new_tab/components/app.style.ts b/browser/resources/brave_new_tab/components/app.style.ts new file mode 100644 index 000000000000..35a4e44d4228 --- /dev/null +++ b/browser/resources/brave_new_tab/components/app.style.ts @@ -0,0 +1,199 @@ +/* Copyright (c) 2025 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, global } from '../lib/scoped_css' + +export const style = scoped.css` + + & { + --search-transition-duration: 120ms; + } + + .top-controls { + position: absolute; + inset-block-start: 4px; + inset-inline-end: 4px; + min-height: 24px; + display: flex; + gap: 8px; + align-items: center; + z-index: 1; + } + + .settings { + --leo-icon-size: 20px; + + opacity: 0.5; + color: #fff; + filter: drop-shadow(0px 1px 4px rgba(0, 0, 0, 0.60)); + + &:hover { + opacity: 0.7; + cursor: pointer; + } + } + + .clock { + font: ${font.large.semibold}; + color: #fff; + opacity: .8; + } + + main { + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; + gap: 16px; + padding: 16px 24px; + + > * { + transition: + opacity var(--search-transition-duration), + transform var(--search-transition-duration); + + .search-box-expanded & { + opacity: 0; + transform: scale(0.9); + } + } + } + + .topsites-container { + padding: 16px 0; + align-self: stretch; + display: flex; + gap: 16px; + } + + .searchbox-container { + flex: 1 1 auto; + align-self: stretch; + + .search-box-expanded & { + opacity: 1; + transform: none; + } + } + + .widget-container { + align-self: stretch; + flex: 0 0 120px; + display: flex; + justify-content: center; + align-items: stretch; + gap: 16px; + } + + &.widget-position-top { + .widget-container { + order: 1; + margin-top: 16px; + margin-bottom: 18px; + } + + .searchbox-container { + order: 2; + } + + .background-caption-container { + order: 3; + } + + .topsites-container { + order: 4; + } + } + +` + +global.css` + @scope (${style.selector}) { + + & { + font: ${font.default.regular}; + color: ${color.text.primary}; + interpolate-size: allow-keywords; + } + + button { + margin: 0; + padding: 0; + background: 0; + border: none; + text-align: unset; + width: unset; + font: inherit; + cursor: pointer; + + &:disabled { + cursor: default; + } + } + + h2 { + font: ${font.heading.h2}; + margin: 0; + } + + h3 { + font: ${font.heading.h3}; + margin: 0; + } + + h4 { + 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/browser/resources/brave_new_tab/components/app.tsx b/browser/resources/brave_new_tab/components/app.tsx new file mode 100644 index 000000000000..8455a11dde38 --- /dev/null +++ b/browser/resources/brave_new_tab/components/app.tsx @@ -0,0 +1,71 @@ +/* Copyright (c) 2025 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 { useNewTabState } from './context/new_tab_context' +import { SearchBox } from './search/search_box' +import { Background } from './background' +import { BackgroundCaption } from './background_caption' +import { SettingsModal, SettingsView } from './settings/settings_modal' +import { TopSites } from './top_sites/top_sites' +import { Clock } from './clock' +import { StatsWidget } from './widgets/stats_widget' +import { ProductWidgetStack } from './widgets/product_widget_stack' + +import { style } from './app.style' + +export function App() { + const widgetPosition = useNewTabState((state) => state.widgetPosition) + + const [settingsView, setSettingsView] = + React.useState(null) + + return ( +
+
+ + +
+
+
+ +
+
+ setSettingsView('search')} + /> +
+
+ +
+
+ + +
+
+ + setSettingsView(null)} + /> +
+ ) +} diff --git a/browser/resources/brave_new_tab/components/background.style.ts b/browser/resources/brave_new_tab/components/background.style.ts new file mode 100644 index 000000000000..13bb8486f602 --- /dev/null +++ b/browser/resources/brave_new_tab/components/background.style.ts @@ -0,0 +1,47 @@ +/* Copyright (c) 2025 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 { scoped } from '../lib/scoped_css' + +export const style = scoped.css` + + & { + pointer-events: none; + position: fixed; + inset: 0; + z-index: -1; + display: flex; + animation-name: fade-in; + animation-timing-function: ease-in-out; + animation-duration: 350ms; + animation-delay: 0s; + animation-fill-mode: both; + + > div { + flex: 1 1 auto; + background-size: cover; + background-repeat: no-repeat; + background-position: center center; + } + } + + .image-background { + background: + linear-gradient( + rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0) 35%, rgba(0, 0, 0, 0) 80%, + rgba(0, 0, 0, 0.6) 100%), + var(--ntp-background); + } + + .color-background { + background: var(--ntp-background); + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + +` diff --git a/browser/resources/brave_new_tab/components/background.tsx b/browser/resources/brave_new_tab/components/background.tsx new file mode 100644 index 000000000000..e9b3b8c629c9 --- /dev/null +++ b/browser/resources/brave_new_tab/components/background.tsx @@ -0,0 +1,68 @@ +/* Copyright (c) 2025 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 { useNewTabState } from './context/new_tab_context' +import { loadImage } from '../lib/image_loader' + +import { style } from './background.style' + +function setBackgroundVariable(value: string) { + if (value) { + document.body.style.setProperty('--ntp-background', value) + } else { + document.body.style.removeProperty('--ntp-background') + } +} + +function ImageBackground(props: { url: string }) { + // In order to avoid a "flash-of-unloaded-image", load the image in the + // background and only update the background CSS variable when the image has + // finished loading. + React.useEffect(() => { + loadImage(props.url).then((loaded) => { + if (loaded) { + setBackgroundVariable(`url(${CSS.escape(props.url)})`) + } + }) + }, [props.url]) + + return
+} + +function ColorBackground(props: { colorValue: string }) { + React.useEffect(() => { + setBackgroundVariable(props.colorValue) + }, [props.colorValue]) + + return
+} + +export function Background() { + const currentBackground = useNewTabState((state) => state.currentBackground) + + function renderBackground() { + if (!currentBackground) { + return + } + + switch (currentBackground.type) { + case 'brave': + case 'custom': + case 'sponsored': + return + case 'solid': + case 'gradient': + return + } + } + + return ( +
+ {renderBackground()} +
+ ) +} diff --git a/browser/resources/brave_new_tab/components/background_caption.style.ts b/browser/resources/brave_new_tab/components/background_caption.style.ts new file mode 100644 index 000000000000..45446fedec98 --- /dev/null +++ b/browser/resources/brave_new_tab/components/background_caption.style.ts @@ -0,0 +1,47 @@ +/* Copyright (c) 2025 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` + a { + text-decoration: none; + color: inherit; + } + + .photo-credits { + color: ${color.white}; + font: ${font.xSmall.regular}; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.10); + opacity: .5; + } + + .sponsored-logo { + --leo-icon-size: 20px; + + display: flex; + flex-direction: column; + align-items: end; + color: ${color.white}; + + leo-icon { + opacity: 0; + transition: opacity 200ms; + } + + img { + margin: 2px 20px 0 20px; + width: 170px; + height: auto; + } + + &:hover { + leo-icon { + opacity: .7; + } + } + } +` diff --git a/browser/resources/brave_new_tab/components/background_caption.tsx b/browser/resources/brave_new_tab/components/background_caption.tsx new file mode 100644 index 000000000000..ba996ae28e51 --- /dev/null +++ b/browser/resources/brave_new_tab/components/background_caption.tsx @@ -0,0 +1,67 @@ +/* Copyright (c) 2025 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 formatMessage from '$web-common/formatMessage' +import { Link } from './link' +import { useLocale } from './context/locale_context' +import { useNewTabState } from './context/new_tab_context' + +import { + BraveBackground, + SponsoredImageBackground } from '../models/new_tab_model' + +import { style } from './background_caption.style' + +function BraveBackgroundCredits(props: { background: BraveBackground}) { + const { getString } = useLocale() + const { author, link } = props.background + if (!author) { + return null + } + return ( + + {formatMessage(getString('photoCreditsText'), [author])} + + ) +} + +function SponsoredBackgroundLogo( + props: { background: SponsoredImageBackground } +) { + if (!props.background.logo) { + return null + } + const { logo } = props.background + return ( + + + {logo.alt} + + ) +} + +export function BackgroundCaption() { + const currentBackground = useNewTabState((state) => state.currentBackground) + + function renderCaption() { + switch (currentBackground?.type) { + case 'brave': + return + case 'sponsored': + return + default: + return null + } + } + + return ( +
+ {renderCaption()} +
+ ) +} diff --git a/browser/resources/brave_new_tab/components/clock.tsx b/browser/resources/brave_new_tab/components/clock.tsx new file mode 100644 index 000000000000..bc20e288958e --- /dev/null +++ b/browser/resources/brave_new_tab/components/clock.tsx @@ -0,0 +1,45 @@ +/* Copyright (c) 2025 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 { useNewTabState } from './context/new_tab_context' + +export function Clock() { + const [showClock, clockFormat] = useNewTabState((state) => [ + state.showClock, + state.clockFormat + ]) + + const ref = React.useRef(null) + + React.useEffect(() => { + const formatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: 'numeric', + hourCycle: clockFormat || undefined + }) + + function update() { + if (ref.current) { + ref.current.innerText = formatter + .formatToParts(new Date()) + .filter((item) => item.type !== 'dayPeriod') + .map((item) => item.value) + .join('') + } + } + + update() + const timer = setInterval(update, 2000) + return () => clearInterval(timer) + }, [showClock, clockFormat]) + + if (!showClock) { + return null + } + + return
+} diff --git a/browser/resources/brave_new_tab/components/context/locale_context.tsx b/browser/resources/brave_new_tab/components/context/locale_context.tsx new file mode 100644 index 000000000000..721d0034456e --- /dev/null +++ b/browser/resources/brave_new_tab/components/context/locale_context.tsx @@ -0,0 +1,59 @@ +/* Copyright (c) 2025 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 { StringKey } from '../../lib/locale_strings' + +export interface Locale { + getString: (key: StringKey) => string + getPluralString: (key: StringKey, count: number) => Promise +} + +const Context = React.createContext({ + getString: () => '', + getPluralString: async () => '' +}) + +interface Props { + locale: Locale + children: React.ReactNode +} + +export function LocaleContext(props: Props) { + return ( + + {props.children} + + ) +} + +export function useLocale(): Locale { + return React.useContext(Context) +} + +export function usePluralString( + key: StringKey, + count: number | undefined | null +) { + const locale = useLocale() + const [value, setValue] = React.useState('') + + React.useEffect(() => { + if (typeof count !== 'number') { + setValue('') + return + } + let canUpdate = true + locale.getPluralString(key, count).then((newValue) => { + if (canUpdate) { + setValue(newValue) + } + }) + return () => { canUpdate = false } + }, [locale, count]) + + return value +} diff --git a/browser/resources/brave_new_tab/components/context/new_tab_context.tsx b/browser/resources/brave_new_tab/components/context/new_tab_context.tsx new file mode 100644 index 000000000000..4d140464e829 --- /dev/null +++ b/browser/resources/brave_new_tab/components/context/new_tab_context.tsx @@ -0,0 +1,32 @@ +/* Copyright (c) 2025 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 { NewTabModel, NewTabState, defaultModel } from '../../models/new_tab_model' +import { useModelState } from '../../lib/use_model_state' + +const Context = React.createContext(defaultModel()) + +interface Props { + model: NewTabModel + children: React.ReactNode +} + +export function NewTabContext(props: Props) { + return ( + + {props.children} + + ) +} + +export function useNewTabModel(): NewTabModel { + return React.useContext(Context) +} + +export function useNewTabState(map: (state: NewTabState) => T): T { + return useModelState(useNewTabModel(), map) +} diff --git a/browser/resources/brave_new_tab/components/context/rewards_context.tsx b/browser/resources/brave_new_tab/components/context/rewards_context.tsx new file mode 100644 index 000000000000..156013db75fa --- /dev/null +++ b/browser/resources/brave_new_tab/components/context/rewards_context.tsx @@ -0,0 +1,32 @@ +/* Copyright (c) 2025 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 { RewardsModel, RewardsModelState, defaultModel } from '../../models/rewards_model' +import { useModelState } from '../../lib/use_model_state' + +const Context = React.createContext(defaultModel()) + +interface Props { + model: RewardsModel + children: React.ReactNode +} + +export function RewardsContext(props: Props) { + return ( + + {props.children} + + ) +} + +export function useRewardsModel(): RewardsModel { + return React.useContext(Context) +} + +export function useRewardsState(map: (state: RewardsModelState) => T): T { + return useModelState(useRewardsModel(), map) +} diff --git a/browser/resources/brave_new_tab/components/context/search_context.tsx b/browser/resources/brave_new_tab/components/context/search_context.tsx new file mode 100644 index 000000000000..1e505a24d7c0 --- /dev/null +++ b/browser/resources/brave_new_tab/components/context/search_context.tsx @@ -0,0 +1,32 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/components/context/top_sites_context.tsx b/browser/resources/brave_new_tab/components/context/top_sites_context.tsx new file mode 100644 index 000000000000..1bcdc078f295 --- /dev/null +++ b/browser/resources/brave_new_tab/components/context/top_sites_context.tsx @@ -0,0 +1,32 @@ +/* Copyright (c) 2025 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 { TopSitesModel, TopSitesState, defaultModel } from '../../models/top_sites_model' +import { useModelState } from '../../lib/use_model_state' + +const Context = React.createContext(defaultModel()) + +interface Props { + model: TopSitesModel + children: React.ReactNode +} + +export function TopSitesContext(props: Props) { + return ( + + {props.children} + + ) +} + +export function useTopSitesModel(): TopSitesModel { + return React.useContext(Context) +} + +export function useTopSitesState(map: (state: TopSitesState) => T): T { + return useModelState(useTopSitesModel(), map) +} diff --git a/browser/resources/brave_new_tab/components/context/vpn_context.tsx b/browser/resources/brave_new_tab/components/context/vpn_context.tsx new file mode 100644 index 000000000000..4e1f1cab7de1 --- /dev/null +++ b/browser/resources/brave_new_tab/components/context/vpn_context.tsx @@ -0,0 +1,32 @@ +/* Copyright (c) 2025 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 { VPNModel, VPNModelState, defaultModel } from '../../models/vpn_model' +import { useModelState } from '../../lib/use_model_state' + +const Context = React.createContext(defaultModel()) + +interface Props { + model: VPNModel + children: React.ReactNode +} + +export function VPNContext(props: Props) { + return ( + + {props.children} + + ) +} + +export function useVPNModel(): VPNModel { + return React.useContext(Context) +} + +export function useVPNState(map: (state: VPNModelState) => T): T { + return useModelState(useVPNModel(), map) +} diff --git a/browser/resources/brave_new_tab/components/link.tsx b/browser/resources/brave_new_tab/components/link.tsx new file mode 100644 index 000000000000..c5c96a5ef6ec --- /dev/null +++ b/browser/resources/brave_new_tab/components/link.tsx @@ -0,0 +1,37 @@ +/* Copyright (c) 2025 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 { sanitizeExternalURL } from '../lib/url_sanitizer' + +interface Props { + url: string + className?: string + children: React.ReactNode +} + +export function Link(props: Props) { + const url = sanitizeExternalURL(props.url) + if (!url) { + return ( + + {props.children} + + ) + } + return ( + + {props.children} + + ) +} + +export function openLink(url: string) { + const sanitizedURL = sanitizeExternalURL(url) + if (sanitizedURL) { + window.open(sanitizedURL, '_self', 'noopener,noreferrer') + } +} diff --git a/browser/resources/brave_new_tab/components/modal.style.ts b/browser/resources/brave_new_tab/components/modal.style.ts new file mode 100644 index 000000000000..9090a3cc97d2 --- /dev/null +++ b/browser/resources/brave_new_tab/components/modal.style.ts @@ -0,0 +1,57 @@ +/* Copyright (c) 2025 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-fade-duration: 120ms; + + position: fixed; + margin: auto; + border-radius: 16px; + background: ${color.container.background}; + box-shadow: ${effect.elevation['05']}; + display: none; + opacity: 0; + transform: scale(1); + + transition: + opacity var(--self-fade-duration), + transform var(--self-fade-duration), + display var(--self-fade-duration) allow-discrete, + overlay var(--self-fade-duration) allow-discrete; + + &:modal { + display: block; + opacity: 1; + + @starting-style { + opacity: 0; + transform: scale(.9); + } + } + + &::backdrop { + background: rgba(0, 0, 0, 0); + transition: all var(--self-fade-duration) allow-discrete; + } + + &:modal::backdrop { + background: rgba(0, 0, 0, 0.15); + + @starting-style { + background: rgba(0, 0, 0, 0); + } + } + } + + .close { + position: absolute; + inset-block: 16px auto; + inset-inline: auto 16px; + } +` diff --git a/browser/resources/brave_new_tab/components/modal.tsx b/browser/resources/brave_new_tab/components/modal.tsx new file mode 100644 index 000000000000..20414e36d545 --- /dev/null +++ b/browser/resources/brave_new_tab/components/modal.tsx @@ -0,0 +1,63 @@ +/* Copyright (c) 2025 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 { style } from './modal.style' + +interface Props { + isOpen: boolean + showClose?: boolean + onClose: () => void + children: React.ReactNode +} + +export function Modal(props: Props) { + const dialogRef = React.useRef(null) + + React.useEffect(() => { + if (props.isOpen) { + dialogRef.current?.showModal() + } else { + dialogRef.current?.close() + } + }, [props.isOpen]) + + function maybeCloseOnBackdropEvent(event: React.MouseEvent) { + if (!props.isOpen || !dialogRef.current) { + return + } + let rect = dialogRef.current.getBoundingClientRect() + let { clientX, clientY } = event + if (clientX < rect.left || clientY < rect.top || + clientX > rect.right || clientY > rect.bottom) { + props.onClose() + } + } + + return ( + + {props.children} + { + props.showClose && + + } + + ) +} diff --git a/browser/resources/brave_new_tab/components/pcdn_image.tsx b/browser/resources/brave_new_tab/components/pcdn_image.tsx new file mode 100644 index 000000000000..344f7f150d3d --- /dev/null +++ b/browser/resources/brave_new_tab/components/pcdn_image.tsx @@ -0,0 +1,26 @@ +/* Copyright (c) 2025 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 './context/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/browser/resources/brave_new_tab/components/popover.tsx b/browser/resources/brave_new_tab/components/popover.tsx new file mode 100644 index 000000000000..82f3cd6d05d8 --- /dev/null +++ b/browser/resources/brave_new_tab/components/popover.tsx @@ -0,0 +1,46 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/components/search/engine_icon.tsx b/browser/resources/brave_new_tab/components/search/engine_icon.tsx new file mode 100644 index 000000000000..9730cf0cabee --- /dev/null +++ b/browser/resources/brave_new_tab/components/search/engine_icon.tsx @@ -0,0 +1,47 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/components/search/search_box.style.ts b/browser/resources/brave_new_tab/components/search/search_box.style.ts new file mode 100644 index 000000000000..cbe95548c1ed --- /dev/null +++ b/browser/resources/brave_new_tab/components/search/search_box.style.ts @@ -0,0 +1,157 @@ +/* Copyright (c) 2025 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: var(--search-transition-duration, 120ms); + + 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}; + color: ${color.text.primary}; + + &: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/browser/resources/brave_new_tab/components/search/search_box.tsx b/browser/resources/brave_new_tab/components/search/search_box.tsx new file mode 100644 index 000000000000..5579b8c15bc9 --- /dev/null +++ b/browser/resources/brave_new_tab/components/search/search_box.tsx @@ -0,0 +1,308 @@ +/* Copyright (c) 2025 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 '../context/search_context' +import { optional } from '../../lib/optional' +import { urlFromInput } from '../../lib/url_input' +import { useLocale } from '../context/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]) + + // Allow document styles to update based on expand state. + React.useEffect(() => { + document.body.classList.toggle('search-box-expanded', expanded) + }, [expanded]) + + 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/browser/resources/brave_new_tab/components/search/search_results.style.ts b/browser/resources/brave_new_tab/components/search/search_results.style.ts new file mode 100644 index 000000000000..e07417434f1a --- /dev/null +++ b/browser/resources/brave_new_tab/components/search/search_results.style.ts @@ -0,0 +1,120 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/components/search/search_results.tsx b/browser/resources/brave_new_tab/components/search/search_results.tsx new file mode 100644 index 000000000000..70b1ee6092c4 --- /dev/null +++ b/browser/resources/brave_new_tab/components/search/search_results.tsx @@ -0,0 +1,144 @@ +/* Copyright (c) 2025 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 '../context/locale_context' +import { useSearchModel, useSearchState } from '../context/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/browser/resources/brave_new_tab/components/settings/background_panel.style.ts b/browser/resources/brave_new_tab/components/settings/background_panel.style.ts new file mode 100644 index 000000000000..245fd427ca5c --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/background_panel.style.ts @@ -0,0 +1,100 @@ +/* Copyright (c) 2025 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; + } + } + + .background-options { + display: flex; + flex-wrap: wrap; + gap: 16px; + + button { + display: flex; + flex-direction: column; + gap: 8px; + } + } + + .preview { + background: var(--preview-background, ${color.container.highlight}); + background-size: cover; + background-repeat: no-repeat; + background-position: center center; + border-radius: 10px; + width: 198px; + height: 156px; + } + + .background-option { + position: relative; + + &:hover .remove-image { + visibility: visible; + } + } + + .allow-remove:hover .selected-marker { + visibility: hidden; + } + + .remove-image { + --leo-icon-size: 24px; + + position: absolute; + inset-block-start: 10px; + inset-inline-end: 10px; + background-color: #fff; + border-radius: 50%; + box-shadow: rgba(0, 0, 0, 0.5) 0px 0px 5px; + padding: 6px; + visibility: hidden; + + &:hover { + color: ${color.icon.interactive}; + } + } + + .upload { + --leo-icon-size: 36px; + --leo-progressring-size: 36px; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 16px; + border: solid 2px ${color.divider.subtle}; + font: ${font.small.regular}; + } + + h4 button { + --leo-icon-size: 20px; + + display: flex; + align-items: center; + gap: 4px; + + &:hover { + color: ${color.text.interactive}; + } + } +` diff --git a/browser/resources/brave_new_tab/components/settings/background_panel.tsx b/browser/resources/brave_new_tab/components/settings/background_panel.tsx new file mode 100644 index 000000000000..49b7f2264bd7 --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/background_panel.tsx @@ -0,0 +1,196 @@ +/* Copyright (c) 2025 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 ProgressRing from '@brave/leo/react/progressRing' +import Toggle from '@brave/leo/react/toggle' + +import { BackgroundType } from '../../models/new_tab_model' +import { useNewTabModel, useNewTabState } from '../context/new_tab_context' +import { useLocale } from '../context/locale_context' +import { inlineCSSVars } from '../../lib/inline_css_vars' +import { BackgroundTypePanel } from './background_type_panel' + +import { + backgroundCSSValue, + gradientPreviewBackground, + solidPreviewBackground } from '../../models/backgrounds' + +import { style } from './background_panel.style' + +export function BackgroundPanel() { + const { getString } = useLocale() + const model = useNewTabModel() + + const [ + backgroundsEnabled, + backgroundsCustomizable, + sponsoredImagesEnabled, + selectedBackgroundType, + selectedBackground, + braveBackgrounds, + customBackgrounds + ] = useNewTabState((state) => [ + state.backgroundsEnabled, + state.backgroundsCustomizable, + state.sponsoredImagesEnabled, + state.selectedBackgroundType, + state.selectedBackground, + state.braveBackgrounds, + state.customBackgrounds + ]) + + const [panelType, setPanelType] = React.useState('none') + const [uploading, setUploading] = React.useState(false) + + React.useEffect(() => { + setUploading(false) + }, [selectedBackground, customBackgrounds]) + + function getTypePreviewValue(type: BackgroundType) { + const isSelectedType = type === selectedBackgroundType + switch (type) { + case 'brave': + return braveBackgrounds[0]?.imageUrl ?? '' + case 'custom': + if (isSelectedType && selectedBackground) { + return selectedBackground + } + return customBackgrounds[0] ?? '' + case 'solid': + if (isSelectedType && selectedBackground) { + return selectedBackground + } + return solidPreviewBackground + case 'gradient': + if (isSelectedType && selectedBackground) { + return selectedBackground + } + return gradientPreviewBackground + case 'none': + return '' + } + } + + function renderUploadPreview() { + return ( +
+ {uploading ? : } + {getString('uploadBackgroundLabel')} +
+ ) + } + + function renderTypePreview(type: BackgroundType) { + if (type === 'custom' && customBackgrounds.length === 0) { + return renderUploadPreview() + } + return ( +
+ { + type === selectedBackgroundType && + + + + } +
+ ) + } + + function showCustomBackgroundChooser() { + model.showCustomBackgroundChooser().then((backgroundSelected) => { + if (backgroundSelected) { + setUploading(true) + } + }) + } + + function onCustomPreviewClick() { + if (customBackgrounds.length === 0) { + showCustomBackgroundChooser() + } else { + setPanelType('custom') + } + } + + if (panelType !== 'none') { + return ( +
+ ( + + )} + onClose={() => { setPanelType('none') }} + /> +
+ ) + } + + return ( +
+
+ + { model.setBackgroundsEnabled(checked) }} + /> +
+ { + backgroundsEnabled && backgroundsCustomizable && <> +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + } + { + backgroundsEnabled && +
+ + { + model.setSponsoredImagesEnabled(checked) + }} + /> +
+ } +
+ ) +} diff --git a/browser/resources/brave_new_tab/components/settings/background_type_panel.tsx b/browser/resources/brave_new_tab/components/settings/background_type_panel.tsx new file mode 100644 index 000000000000..f2f6feae4558 --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/background_type_panel.tsx @@ -0,0 +1,143 @@ +/* Copyright (c) 2025 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 Toggle from '@brave/leo/react/toggle' + +import { BackgroundType } from '../../models/new_tab_model' +import { useNewTabModel, useNewTabState } from '../context/new_tab_context' +import { useLocale } from '../context/locale_context' +import { inlineCSSVars } from '../../lib/inline_css_vars' +import classNames from '$web-common/classnames' + +import { + backgroundCSSValue, + solidBackgrounds, + gradientBackgrounds } from '../../models/backgrounds' + +interface Props { + backgroundType: BackgroundType + renderUploadOption: () => React.ReactNode + onClose: () => void +} + +export function BackgroundTypePanel(props: Props) { + const { getString } = useLocale() + const model = useNewTabModel() + + const [ + selectedBackgroundType, + selectedBackground, + customBackgrounds, + currentBackground + ] = useNewTabState((state) => [ + state.selectedBackgroundType, + state.selectedBackground, + state.customBackgrounds, + state.currentBackground + ]) + + const type = props.backgroundType + + function panelTitle() { + switch (type) { + case 'custom': return getString('customBackgroundTitle') + case 'gradient': return getString('gradientBackgroundTitle') + case 'solid': return getString('solidBackgroundTitle') + default: return '' + } + } + + function panelValues() { + switch (type) { + case 'custom': return customBackgrounds + case 'gradient': return gradientBackgrounds + case 'solid': return solidBackgrounds + default: return [] + } + } + + function onRandomizeToggle(detail: { checked: boolean }) { + if (detail.checked) { + model.selectBackground(type, '') + } else if (currentBackground) { + switch (currentBackground.type) { + case 'custom': + model.selectBackground(type, currentBackground.imageUrl) + break + case 'solid': + case 'gradient': + model.selectBackground(type, currentBackground.cssValue) + break + default: + break + } + } + } + + const values = panelValues() + + return <> +

+ +

+
+ + +
+
+ {values.map((value) => { + const isSelected = + selectedBackgroundType === type && + selectedBackground === value + + return ( +
+ + { + type === 'custom' && + + } +
+ ) + })} + {type === 'custom' && props.renderUploadOption()} +
+ +} diff --git a/browser/resources/brave_new_tab/components/settings/clock_panel.style.ts b/browser/resources/brave_new_tab/components/settings/clock_panel.style.ts new file mode 100644 index 000000000000..741e1c515910 --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/clock_panel.style.ts @@ -0,0 +1,23 @@ +/* Copyright (c) 2025 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 { scoped } from '../../lib/scoped_css' + +export const style = scoped.css` + & { + display: flex; + flex-direction: column; + gap: 16px; + + > * { + display: flex; + align-items: center; + + label { + flex: 1 1 auto; + } + } + } +` diff --git a/browser/resources/brave_new_tab/components/settings/clock_panel.tsx b/browser/resources/brave_new_tab/components/settings/clock_panel.tsx new file mode 100644 index 000000000000..bf61fc535d0c --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/clock_panel.tsx @@ -0,0 +1,71 @@ +/* Copyright (c) 2025 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 DropDown from '@brave/leo/react/dropdown' +import Toggle from '@brave/leo/react/toggle' + +import { ClockFormat } from '../../models/new_tab_model' +import { useLocale } from '../context/locale_context' +import { useNewTabModel, useNewTabState } from '../context/new_tab_context' +import formatMessage from '$web-common/formatMessage' + +import { style } from './clock_panel.style' + +export function ClockPanel() { + const { getString } = useLocale() + const model = useNewTabModel() + + const [showClock, clockFormat] = useNewTabState((state) => [ + state.showClock, + state.clockFormat + ]) + + function formatOptionText(format: ClockFormat) { + switch (format) { + case 'h12': + return getString('clockFormatOption12HourText') + case 'h24': + return getString('clockFormatOption24HourText') + case '': + return formatMessage(getString('clockFormatOptionAutomaticText'), [ + new Intl.DateTimeFormat(undefined).resolvedOptions().locale + ]).join('') + } + } + + function renderFormatOption(format: ClockFormat) { + return ( + {formatOptionText(format)} + ) + } + + return ( +
+
+ + { model.setShowClock(checked) }} + /> +
+
+ + { + model.setClockFormat(detail.value as ClockFormat) + }} + > + {renderFormatOption('')} + {renderFormatOption('h12')} + {renderFormatOption('h24')} + +
+
+ ) +} diff --git a/browser/resources/brave_new_tab/components/settings/search_panel.style.ts b/browser/resources/brave_new_tab/components/settings/search_panel.style.ts new file mode 100644 index 000000000000..27379d33879e --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/search_panel.style.ts @@ -0,0 +1,62 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/components/settings/search_panel.tsx b/browser/resources/brave_new_tab/components/settings/search_panel.tsx new file mode 100644 index 000000000000..dc69d7104500 --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/search_panel.tsx @@ -0,0 +1,78 @@ +/* Copyright (c) 2025 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 '../context/search_context' +import { useLocale } from '../context/locale_context' +import { EngineIcon } from '../search/engine_icon' +import { Link } from '../link' + +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} + + + ) + }) + } +
+
+
+ + {getString('customizeSearchEnginesLink')} + + +
+ + } +
+ ) +} diff --git a/browser/resources/brave_new_tab/components/settings/settings_modal.style.ts b/browser/resources/brave_new_tab/components/settings/settings_modal.style.ts new file mode 100644 index 000000000000..9dfc93115880 --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/settings_modal.style.ts @@ -0,0 +1,57 @@ +/* Copyright (c) 2025 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 } from '@brave/leo/tokens/css/variables' +import { scoped, global } from '../../lib/scoped_css' + +export const style = scoped.css` + & { + --leo-dialog-width: 720px; + --leo-dialog-padding: 0; + --leo-dialog-background: ${color.container.background}; + + height: 0; + } + + h3 { + margin: 24px 24px 16px; + } + + .panel-body { + display: flex; + gap: 16px; + } + + nav { + flex: 0 0 244px; + white-space: nowrap; + } + + section { + flex: 1 1 auto; + padding: 10px 16px 16px; + height: 360px; + overflow: auto; + overscroll-behavior: contain; + } +` + +global.css` + @scope (${style.selector}) { + + .selected-marker { + --leo-icon-color: #fff; + --leo-icon-size: 24px; + + position: absolute; + inset-block-start: 10px; + inset-inline-end: 10px; + background: ${color.icon.interactive}; + border-radius: 50%; + padding: 6px; + } + + } +` diff --git a/browser/resources/brave_new_tab/components/settings/settings_modal.tsx b/browser/resources/brave_new_tab/components/settings/settings_modal.tsx new file mode 100644 index 000000000000..b5f74a50ef5b --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/settings_modal.tsx @@ -0,0 +1,116 @@ +/* Copyright (c) 2025 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 Dialog from '@brave/leo/react/dialog' +import Navigation from '@brave/leo/react/navigation' +import NavigationItem from '@brave/leo/react/navigationItem' + +import { useSearchState } from '../context/search_context' +import { BackgroundPanel } from './background_panel' +import { TopSitesPanel } from './top_sites_panel' +import { SearchPanel } from './search_panel' +import { ClockPanel } from './clock_panel' +import { WidgetsPanel } from './widgets_panel' +import { useLocale } from '../context/locale_context' + +import { style } from './settings_modal.style' + +export type SettingsView = + 'background' | + 'top-sites' | + 'search' | + 'clock' | + 'widgets' + +interface Props { + initialView: SettingsView | null + isOpen: boolean + onClose: () => void +} + +export function SettingsModal(props: Props) { + const { getString } = useLocale() + + const searchFeatureEnabled = useSearchState( + (state) => state.searchFeatureEnabled) + + const [currentView, setCurrentView] = + React.useState(props.initialView || 'background') + + React.useEffect(() => { + if (props.isOpen) { + setCurrentView(props.initialView || 'background') + } + }, [props.isOpen, props.initialView]) + + function shouldHideView(view: SettingsView) { + switch (view) { + case 'search': return !searchFeatureEnabled + default: return false + } + } + + function renderPanel() { + if (shouldHideView(currentView)) { + return null + } + switch (currentView) { + case 'background': return + case 'top-sites': return + case 'search': return + case 'clock': return + case 'widgets': return + } + } + + function getNavItemText(view: SettingsView) { + switch (view) { + case 'background': return getString('backgroundSettingsTitle') + case 'top-sites': return getString('topSitesSettingsTitle') + case 'search': return getString('searchSettingsTitle') + case 'clock': return getString('clockSettingsTitle') + case 'widgets': return getString('widgetSettingsTitle') + } + } + + function renderNavItem(view: SettingsView) { + if (shouldHideView(view)) { + return null + } + return ( + setCurrentView(view)} + > + {getNavItemText(view)} + + ) + } + + return ( +
+ +

+ {getString('settingsTitle')} +

+
+ +
+ {renderPanel()} +
+
+
+
+ ) +} diff --git a/browser/resources/brave_new_tab/components/settings/top_sites_panel.style.ts b/browser/resources/brave_new_tab/components/settings/top_sites_panel.style.ts new file mode 100644 index 000000000000..fea6ef24d7cc --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/top_sites_panel.style.ts @@ -0,0 +1,99 @@ +/* Copyright (c) 2025 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 } from '@brave/leo/tokens/css/variables' +import { scoped } from '../../lib/scoped_css' + +import favoritesActive from '../../assets/favorites_active.svg' +import favoritesActiveDark from '../../assets/favorites_active_dark.svg' +import favoritesInactive from '../../assets/favorites_inactive.svg' +import favoritesInactiveDark from '../../assets/favorites_inactive_dark.svg' +import frequentlyVisitedActive from '../../assets/frequently_visited_active.svg' +import frequentlyVisitedActiveDark from '../../assets/frequently_visited_active_dark.svg' +import frequentlyVisitedInactive from '../../assets/frequently_visited_inactive.svg' +import frequentlyVisitedInactiveDark from '../../assets/frequently_visited_inactive_dark.svg' + +export const style = scoped.css` + & { + display: flex; + flex-direction: column; + gap: 16px; + } + + .toggle-row { + display: flex; + align-items: center; + + label { + flex: 1 1 auto; + } + } + + .list-view-options { + display: flex; + flex-wrap: wrap; + gap: 24px; + align-items: flex-start; + + > * { + flex: 1 1 190px; + display: flex; + flex-direction: column; + } + } + + h4 { + margin-top: 8px; + } + + .list-view-image { + width: 100%; + flex-basis: 146px; + border-radius: 8px; + border: solid 1px ${color.divider.subtle}; + padding: 16px; + background-position: center; + background-repeat: no-repeat; + background-size: auto 114px; + position: relative; + + .active & { + border: solid 2px ${color.icon.interactive}; + background-color: ${color.primary['10']}; + } + } + + .custom { + .list-view-image { + background-image: url(${favoritesInactive}); + @media (prefers-color-scheme: dark) { + background-image: url(${favoritesInactiveDark}); + } + } + + &.active .list-view-image { + background-image: url(${favoritesActive}); + @media (prefers-color-scheme: dark) { + background-image: url(${favoritesActiveDark}); + } + } + } + + .most-visited { + .list-view-image { + background-image: url(${frequentlyVisitedInactive}); + @media (prefers-color-scheme: dark) { + background-image: url(${frequentlyVisitedInactiveDark}); + } + } + + &.active .list-view-image { + background-image: url(${frequentlyVisitedActive}); + @media (prefers-color-scheme: dark) { + background-image: url(${frequentlyVisitedActiveDark}); + } + } + } +` diff --git a/browser/resources/brave_new_tab/components/settings/top_sites_panel.tsx b/browser/resources/brave_new_tab/components/settings/top_sites_panel.tsx new file mode 100644 index 000000000000..a29935e3470c --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/top_sites_panel.tsx @@ -0,0 +1,84 @@ +/* Copyright (c) 2025 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 Toggle from '@brave/leo/react/toggle' + +import { TopSitesListKind } from '../../models/top_sites_model' +import { useTopSitesModel, useTopSitesState } from '../context/top_sites_context' +import { useLocale } from '../context/locale_context' + +import { style } from './top_sites_panel.style' + +export function TopSitesPanel() { + const { getString } = useLocale() + const model = useTopSitesModel() + + const [ + showTopSites, + listKind + ] = useTopSitesState((state) => [ + state.showTopSites, + state.listKind + ]) + + function listOptionClassName(kind: TopSitesListKind) { + if (kind === listKind) { + return kind + ' active' + } + return kind + } + + function renderSelectedMarker(kind: TopSitesListKind) { + if (kind === listKind) { + return ( + + + + ) + } + return null + } + + return ( +
+
+ + { model.setShowTopSites(checked) }} + /> +
+ { + showTopSites && ( +
+ + +
+ ) + } +
+ ) +} diff --git a/browser/resources/brave_new_tab/components/settings/widget_position_icon.tsx b/browser/resources/brave_new_tab/components/settings/widget_position_icon.tsx new file mode 100644 index 000000000000..5595f7de23d3 --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/widget_position_icon.tsx @@ -0,0 +1,35 @@ +/* Copyright (c) 2025 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 { WidgetPosition } from '../../models/new_tab_model' + +export function TopIcon() { + return ( + + + + ) +} + +export function BottomIcon() { + return ( + + + + ) +} + +interface Props { + position: WidgetPosition +} + +export function WidgetPositionIcon(props: Props) { + switch (props.position) { + case 'top': return + case 'bottom': return + } +} diff --git a/browser/resources/brave_new_tab/components/settings/widgets_panel.style.ts b/browser/resources/brave_new_tab/components/settings/widgets_panel.style.ts new file mode 100644 index 000000000000..2bf7e5bae988 --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/widgets_panel.style.ts @@ -0,0 +1,37 @@ +/* Copyright (c) 2025 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 { scoped } from '../../lib/scoped_css' + +export const style = scoped.css` + & { + display: flex; + flex-direction: column; + gap: 16px; + + > * { + display: flex; + align-items: center; + + label { + flex: 1 1 auto; + } + } + } + + .layout-control { + --leo-segmented-control-height: 36px; + } + + leo-controlitem { + --leo-control-item-padding: 4px; + + svg { + display: block; + height: 18px; + width: auto; + } + } +` diff --git a/browser/resources/brave_new_tab/components/settings/widgets_panel.tsx b/browser/resources/brave_new_tab/components/settings/widgets_panel.tsx new file mode 100644 index 000000000000..ff3ec972633c --- /dev/null +++ b/browser/resources/brave_new_tab/components/settings/widgets_panel.tsx @@ -0,0 +1,125 @@ +/* Copyright (c) 2025 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 ControlItem from '@brave/leo/react/controlItem' +import SegmentedControl from '@brave/leo/react/segmentedControl' +import Toggle from '@brave/leo/react/toggle' + +import { useLocale } from '../context/locale_context' +import { useNewTabModel, useNewTabState } from '../context/new_tab_context' +import { useVPNModel, useVPNState } from '../context/vpn_context' +import { useRewardsModel, useRewardsState } from '../context/rewards_context' +import { WidgetPosition } from '../../models/new_tab_model' +import { WidgetPositionIcon } from './widget_position_icon' + +import { style } from './widgets_panel.style' + +export function WidgetsPanel() { + const { getString } = useLocale() + + const newTabModel = useNewTabModel() + const vpnModel = useVPNModel() + const rewardsModel = useRewardsModel() + + const [ + widgetPosition, + showStats, + showTalkWidget + ] = useNewTabState((state) => [ + state.widgetPosition, + state.showShieldsStats, + state.showTalkWidget + ]) + + const [ + showVpnWidget, + vpnFeatureEnabled + ] = useVPNState((state) => [ + state.showVpnWidget, + state.vpnFeatureEnabled + ]) + + const [ + showRewardsWidget, + rewardsFeatureEnabled + ] = useRewardsState((state) => [ + state.showRewardsWidget, + state.rewardsFeatureEnabled + ]) + + function renderWidgetPositionItem(position: WidgetPosition) { + return ( + + + + ) + } + + return ( +
+
+ + { + switch (value) { + case 'top': + case 'bottom': + newTabModel.setWidgetPosition(value) + break + } + }} + > + {renderWidgetPositionItem('bottom')} + {renderWidgetPositionItem('top')} + +
+
+ + { + newTabModel.setShowShieldsStats(checked) + }} + /> +
+ { + vpnFeatureEnabled && +
+ + { vpnModel.setShowVpnWidget(checked) }} + /> +
+ } + { + rewardsFeatureEnabled && +
+ + { + rewardsModel.setShowRewardsWidget(checked) + }} + /> +
+ } +
+ + { newTabModel.setShowTalkWidget(checked) }} + /> +
+
+ ) +} diff --git a/browser/resources/brave_new_tab/components/top_sites/remove_toast.style.ts b/browser/resources/brave_new_tab/components/top_sites/remove_toast.style.ts new file mode 100644 index 000000000000..fa686057f374 --- /dev/null +++ b/browser/resources/brave_new_tab/components/top_sites/remove_toast.style.ts @@ -0,0 +1,63 @@ +/* Copyright (c) 2025 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` + & { + position: absolute; + } + + .toast { + position: fixed; + inset: 50px 0 0; + margin: 0 auto; + width: 450px; + max-width: calc(100vw - 32px); + + border-radius: 8px; + background: ${color.systemfeedback.infoBackground}; + box-shadow: ${effect.elevation['04']}; + transform: translateY(-50px); + opacity: 0; + + transition: + opacity 100ms, + transform 100ms, + display 100ms allow-discrete, + overlay 100ms allow-discrete; + + &:popover-open { + transform: translateY(0); + opacity: 1; + + @starting-style { + transform: translateY(-50px); + opacity: 0; + } + } + } + + .content { + --leo-icon-color: ${color.systemfeedback.infoIcon}; + --leo-icon-size: 20px; + + display: flex; + align-items: center; + gap: 16px; + + padding: 16px; + color: ${color.systemfeedback.infoText}; + } + + .text { + flex-grow: 1; + } + + leo-button { + flex-grow: 0; + } +` diff --git a/browser/resources/brave_new_tab/components/top_sites/remove_toast.tsx b/browser/resources/brave_new_tab/components/top_sites/remove_toast.tsx new file mode 100644 index 000000000000..7314234cbdea --- /dev/null +++ b/browser/resources/brave_new_tab/components/top_sites/remove_toast.tsx @@ -0,0 +1,63 @@ +/* Copyright (c) 2025 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 { useLocale } from '../context/locale_context' +import { Popover } from '../popover' + +import { style } from './remove_toast.style' + +interface Props { + isOpen: boolean + onUndo: () => void + onClose: () => void +} + +export function RemoveToast(props: Props) { + const { getString } = useLocale() + const timeout: React.MutableRefObject = React.useRef(0) + + function cancelTimer() { + if (timeout.current) { + clearTimeout(timeout.current) + timeout.current = 0 + } + } + + React.useEffect(() => { + if (!props.isOpen) { + return + } + timeout.current = setTimeout(props.onClose, 4000) as any + return cancelTimer + }, [props.isOpen, props.onClose]) + + return ( +
+ +
+ +
+

{getString('topSiteRemovedTitle')}

+

{getString('topSiteRemovedText')}

+
+ + +
+
+
+ ) +} diff --git a/browser/resources/brave_new_tab/components/top_sites/top_site_edit_modal.style.ts b/browser/resources/brave_new_tab/components/top_sites/top_site_edit_modal.style.ts new file mode 100644 index 000000000000..1b4d8f9dd5b1 --- /dev/null +++ b/browser/resources/brave_new_tab/components/top_sites/top_site_edit_modal.style.ts @@ -0,0 +1,34 @@ +/* Copyright (c) 2025 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 { font } from '@brave/leo/tokens/css/variables' +import { scoped } from '../../lib/scoped_css' + +export const style = scoped.css` + & { + width: 500px; + max-width: 100%; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + } + + .label { + font: ${font.small.semibold}; + } + + .actions { + padding-top: 8px; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + + leo-button { + flex: 0 0 auto; + } + } +` diff --git a/browser/resources/brave_new_tab/components/top_sites/top_site_edit_modal.tsx b/browser/resources/brave_new_tab/components/top_sites/top_site_edit_modal.tsx new file mode 100644 index 000000000000..92f8826c9da6 --- /dev/null +++ b/browser/resources/brave_new_tab/components/top_sites/top_site_edit_modal.tsx @@ -0,0 +1,98 @@ +/* Copyright (c) 2025 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 Input from '@brave/leo/react/input' + +import { useLocale } from '../context/locale_context' +import { TopSite } from '../../models/top_sites_model' +import { Modal } from '../modal' + +import { style } from './top_site_edit_modal.style' + +function parseURL(url: string) { + try { + return new URL(url) + } catch { + return null + } +} + +function maybeAddProtocol(url: string) { + if (!parseURL(url)) { + const httpsURL = `https://${url}` + if (parseURL(httpsURL)) { + return httpsURL + } + } + return url +} + +interface Props { + topSite: TopSite | null + isOpen: boolean + onClose: () => void + onSave: (url: string, title: string) => void +} + +export function TopSiteEditModal(props: Props) { + const { getString } = useLocale() + const [title, setTitle] = React.useState('') + const [url, setURL] = React.useState('') + + React.useEffect(() => { + if (props.isOpen) { + setTitle(props.topSite?.title ?? '') + setURL(props.topSite?.url ?? '') + } + }, [props.isOpen, props.topSite]) + + function isValidInput() { + return title && Boolean(parseURL(maybeAddProtocol(url))) + } + + function onSubmit() { + if (isValidInput()) { + props.onSave(maybeAddProtocol(url), title) + } + } + + function maybeSubmitOnKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Enter') { + let target = event.target as HTMLElement + if (target.matches('leo-input')) { + onSubmit() + } + } + } + + return ( + +
+

+ {props.topSite ? + getString('editTopSiteTitle') : + getString('addTopSiteTitle') + } +

+ setTitle(detail.value)}> + {getString('topSitesTitleLabel')} + + setURL(detail.value)}> + {getString('topSitesURLLabel')} + +
+ + +
+
+
+ ) +} diff --git a/browser/resources/brave_new_tab/components/top_sites/top_site_tile.tsx b/browser/resources/brave_new_tab/components/top_sites/top_site_tile.tsx new file mode 100644 index 000000000000..81400b17398c --- /dev/null +++ b/browser/resources/brave_new_tab/components/top_sites/top_site_tile.tsx @@ -0,0 +1,175 @@ +/* Copyright (c) 2025 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 { TopSite } from '../../models/top_sites_model' +import { faviconURL } from '../../lib/favicon_url' + +function sanitizeTileURL(url: string) { + try { + return new URL(url).toString() + } catch { + return '' + } +} + +export type DropLocation = 'before' | 'after' + +function getDropLocation(x: number, rect: DOMRect): DropLocation { + const mid = rect.x + (rect.width / 2) + return x < mid ? 'before' : 'after' +} + +function relativeOffset(element: HTMLElement, offsetParent: Element | null) { + const offset = { top: element.offsetTop, left: element.offsetLeft } + let parent = element.offsetParent as HTMLElement + while (parent && parent !== offsetParent) { + offset.top += parent.offsetTop + offset.left += parent.offsetLeft + parent = parent.offsetParent as HTMLElement + } + return offset +} + +function updateDropIndicator(tile: HTMLElement, location: DropLocation | null) { + const indicator = document.querySelector('.tile-drop-indicator') + if (!indicator) { + return + } + if (location) { + indicator.classList.add('dragging') + const offset = relativeOffset(tile, indicator.offsetParent) + if (location === 'after') { + offset.left += tile.offsetWidth + } + indicator.style.top = offset.top + 'px' + indicator.style.left = offset.left + 'px' + } else { + indicator.classList.remove('dragging') + } +} + +interface Props { + topSite: TopSite + canDrag: boolean + onRightClick: (event: React.MouseEvent) => void + onDrop: (url: string, location: DropLocation) => void +} + +export function TopSitesTile(props: Props) { + const rootRef = React.useRef(null) + const dragInfo = React.useRef({ enterCount: 0, updateFrame: 0 }) + + const updateIndicator = React.useCallback( + (point: { x: number, y: number } | null) => { + if (dragInfo.current.updateFrame) { + cancelAnimationFrame(dragInfo.current.updateFrame) + } + dragInfo.current.updateFrame = requestAnimationFrame(() => { + dragInfo.current.updateFrame = 0 + const elem = rootRef.current + if (!elem) { + return + } + if (point) { + const rect = elem.getBoundingClientRect() + const location = getDropLocation(point.x, rect) + updateDropIndicator(elem, location) + } else { + updateDropIndicator(elem, null) + } + }) + }, []) + + const { favicon, title, url } = props.topSite + + function onContextMenu(event: React.MouseEvent) { + event.preventDefault() + } + + function onMouseUp(event: React.MouseEvent) { + if (event.button === 2) { + props.onRightClick(event) + } + } + + function onDragStart(event: React.DragEvent) { + event.dataTransfer.setData('text/uri-list', url) + event.dataTransfer.setData('text/top-site-url', url) + } + + function isTopSiteDrag(event: React.DragEvent) { + return event.dataTransfer.types.includes('text/top-site-url') + } + + function onDragEnter(event: React.DragEvent) { + dragInfo.current.enterCount += 1; + if (dragInfo.current.enterCount === 1 && isTopSiteDrag(event)) { + event.preventDefault() + } + } + + function onDragOver(event: React.DragEvent) { + if (dragInfo.current.enterCount > 0 && isTopSiteDrag(event)) { + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + if (rootRef.current) { + updateIndicator({ x: event.clientX, y: event.clientY }) + } + } + } + + function onDragLeave(event: React.DragEvent) { + dragInfo.current.enterCount -= 1; + if (dragInfo.current.enterCount <= 0) { + updateIndicator(null) + } + } + + function onDrop(event: React.DragEvent) { + event.preventDefault() + + dragInfo.current.enterCount = 0 + updateIndicator(null) + + const dragURL = event.dataTransfer.getData('text/top-site-url') + if (dragURL && rootRef.current) { + const rect = rootRef.current.getBoundingClientRect() + let location = getDropLocation(event.clientX, rect) + if (rootRef.current.matches(':dir(rtl)')) { + // Notify the parent of the drop's logical, rather than physical + // location. In RTL, this means we need to reverse the direction. + location = location === 'before' ? 'after' : 'before' + } + props.onDrop(dragURL, location) + } + } + + return ( + + + + + + {title} + + + ) +} diff --git a/browser/resources/brave_new_tab/components/top_sites/top_sites.style.ts b/browser/resources/brave_new_tab/components/top_sites/top_sites.style.ts new file mode 100644 index 000000000000..ab16fceffa62 --- /dev/null +++ b/browser/resources/brave_new_tab/components/top_sites/top_sites.style.ts @@ -0,0 +1,309 @@ +/* Copyright (c) 2025 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 collapsedTileCount = 6 +export const maxTileColumnCount = 12 +export const maxTileRowCount = 4; +export const maxTileCount = maxTileColumnCount * maxTileRowCount + +export const style = scoped.css` + + & { + --self-tile-width: 72px; + --self-tile-height: 82px; + --self-tile-icon-size: 56px; + --self-transition-duration: 200ms; + --self-backdrop-transition-duration: 100ms; + --self-column-count: min(var(--self-tile-count), ${collapsedTileCount}); + --self-max-grid-width: calc(100vi - 110px); + + anchor-name: --top-sites-anchor; + container-type: inline-size; + + flex-grow: 1; + height: var(--self-tile-height); + } + + &.expanded { + --self-column-count: min(var(--self-tile-count), ${maxTileColumnCount}); + } + + &.hidden { + display: none; + } + + .top-sites { + position: absolute; + position-anchor: --top-sites-anchor; + inset-block-start: anchor(start); + inset-inline-start: anchor(start); + inline-size: 100cqi; + block-size: fit-content; + + display: flex; + align-items: flex-start; + justify-content: center; + gap: 8px; + + .widget-position-top & { + inset-block-start: unset; + inset-block-end: anchor(end); + } + + transition: + overlay var(--self-transition-duration) allow-discrete; + + &::backdrop { + background: rgba(0, 0, 0, 0); + backdrop-filter: blur(0); + transition: all var(--self-backdrop-transition-duration) allow-discrete; + transition-delay: 100ms; + } + + &:popover-open::backdrop { + background: rgba(0, 0, 0, 0.2); + backdrop-filter: blur(25px); + transition-delay: 0s; + + @starting-style { + background: rgba(0, 0, 0, 0); + backdrop-filter: blur(0); + } + } + } + + &.expanded .top-sites { + inline-size: calc(100vi - 56px); + } + + .top-site-tiles-mask { + flex-grow: 1; + overflow-x: hidden; + overflow-y: hidden; + height: var(--self-tile-height); + max-width: calc(var(--self-column-count) * var(--self-tile-width)); + + transition: + height var(--self-transition-duration), + max-width var(--self-transition-duration); + } + + &.expanded .top-site-tiles-mask { + height: fit-content; + margin-bottom: 24px; + } + + .top-site-tiles { + display: flex; + flex-wrap: wrap; + row-gap: 16px; + width: min( + var(--self-max-grid-width), + calc(${maxTileColumnCount} * var(--self-tile-width))); + } + + .top-site-tile { + width: var(--self-tile-width); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-decoration: none; + } + + .top-site-tile:nth-child(n + ${collapsedTileCount + 1}) { + opacity: 1; + + transition-property: opacity, display; + transition-duration: var(--self-transition-duration); + transition-delay: calc(var(--self-transition-duration) - 60ms); + transition-behavior: allow-discrete; + + @starting-style { + opacity: 0; + } + } + + &.collapsed .top-site-tile:nth-child(n + ${collapsedTileCount + 1}) { + display: none; + opacity: 0; + transition-delay: 0s; + } + + .top-site-tile:nth-child(n + ${maxTileCount + 1}) { + display: none; + } + + .top-site-icon { + --leo-icon-size: 32px; + --leo-icon-color: ${color.white}; + + position: relative; + margin: 0 calc((var(--self-tile-width) - var(--self-tile-icon-size)) / 2); + width: var(--self-tile-icon-size); + height: var(--self-tile-icon-size); + padding: 12px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.25); + backdrop-filter: blur(50px); + display: flex; + align-items: center; + justify-content: center; + + /* Gradient border */ + &::before { + content: ""; + position: absolute; + inset: 0; + border-radius: 16px; + padding: 1px; + background: linear-gradient( + 156.52deg, + rgba(0, 0, 0, 0.05) 2.12%, + rgba(0, 0, 0, 0) 39%, + rgba(0, 0, 0, 0) 54.33%, + rgba(0, 0, 0, 0.15) 93.02%); + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + } + + img { + width: 100%; + height: auto; + } + } + + .top-site-tile:hover .top-site-icon { + background: rgba(255, 255, 255, .35); + } + + .top-site-tile:focus-visible { + outline: none; + + .top-site-icon { + background: rgba(255, 255, 255, .35); + } + } + + /* Opacity transitions interact poorly with backdrop blur. For tiles that will + be transitioning opacity, disable backdrop blur. */ + .top-site-tile:nth-child(n + ${collapsedTileCount + 1}) .top-site-icon { + backdrop-filter: blur(0); + } + + .top-site-title { + color: ${color.white}; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font: ${font.small.semibold}; + text-shadow: 0px 1px 4px rgba(0, 0, 0, 0.40); + text-align: center; + } + + .tile-drop-indicator { + position: absolute; + pointer-events: none; + top: 0; + left: 0; + height: var(--self-tile-height); + width: 4px; + border-radius: 2px; + background: ${color.divider.interactive}; + display: none; + opacity: 0; + + transition: display 100ms allow-discrete, opacity 100ms; + + &.dragging { + display: block; + opacity: 1; + + @starting-style { + opacity: 0; + } + } + } + + .menu-button { + --leo-icon-color: rgba(255, 255, 255, .8); + --leo-icon-size: 24px; + + anchor-name: --top-sites-menu-button; + + height: var(--self-tile-icon-size); + display: flex; + align-items: center; + opacity: 0; + border-radius: 12px; + visibility: hidden; + + transition: opacity var(--self-transition-duration); + + &:focus-visible { + opacity: 1; + background: rgba(255, 255, 255, .35); + outline: none; + } + } + + .top-sites-menu { + position-anchor: --top-sites-menu-button; + position-area: block-end span-inline-end; + position-try-fallbacks: flip-block; + } + + .top-site-context-menu-anchor { + position: absolute; + top: var(--self-context-menu-y, 0); + left: var(--self-context-menu-x, 0); + anchor-name: --top-site-context-menu-anchor; + } + + .top-site-context-menu { + position: absolute; + position-anchor: --top-site-context-menu-anchor; + position-area: block-end span-inline-end; + position-try-fallbacks: flip-inline, flip-block; + } + + .expand-button { + --leo-icon-size: 24px; + + height: var(--self-tile-icon-size); + color: rgba(255, 255, 255, .6); + display: flex; + align-items: center; + border-radius: 12px; + background: rgba(255, 255, 255, 0.24); + opacity: 0; + visibility: hidden; + + transition: opacity var(--self-transition-duration); + + &:hover { + background: rgba(255, 255, 255, .35); + } + + &:focus-visible { + opacity: 1; + background: rgba(255, 255, 255, .35); + outline: none; + } + } + + &.collapsed:hover { + .menu-button, .expand-button:not(:disabled) { + opacity: 1; + visibility: visible; + } + } +` diff --git a/browser/resources/brave_new_tab/components/top_sites/top_sites.tsx b/browser/resources/brave_new_tab/components/top_sites/top_sites.tsx new file mode 100644 index 000000000000..819e9f340e5c --- /dev/null +++ b/browser/resources/brave_new_tab/components/top_sites/top_sites.tsx @@ -0,0 +1,244 @@ +/* Copyright (c) 2025 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 { TopSite } from '../../models/top_sites_model' +import { useLocale } from '../context/locale_context' +import { useTopSitesState, useTopSitesModel } from '../context/top_sites_context' +import { inlineCSSVars } from '../../lib/inline_css_vars' +import { RemoveToast } from './remove_toast' +import { TopSitesTile, DropLocation } from './top_site_tile' +import { TopSiteEditModal } from './top_site_edit_modal' +import { Popover } from '../popover' +import classNames from '$web-common/classnames' + +import { style, collapsedTileCount, maxTileCount } from './top_sites.style' + +export function TopSites() { + const { getString } = useLocale() + const model = useTopSitesModel() + + const [ + showTopSites, + listKind, + topSites + ] = useTopSitesState((state) => [ + state.showTopSites, + state.listKind, + state.topSites + ]) + + const [expanded, setExpanded] = React.useState(false) + const [showEditSite, setShowEditSite] = React.useState(false) + const [editSite, setEditSite] = React.useState(null) + const [showTopSitesMenu, setShowTopSitesMenu] = React.useState(false) + const [contextMenuSite, setContextMenuSite] = + React.useState(null) + const [showRemoveToast, setShowRemoveToast] = React.useState(false) + + const rootRef = React.useRef(null) + + const showAddButton = listKind === 'custom' + const tileCount = topSites.length + (showAddButton ? 1 : 0) + + function onTopSiteContextMenu(topSite: TopSite) { + return (event: React.MouseEvent) => { + setContextMenuSite(topSite) + const elem = rootRef.current + if (elem) { + elem.style.setProperty('--self-context-menu-x', event.pageX + 'px') + elem.style.setProperty('--self-context-menu-y', event.pageY + 'px') + } + } + } + + function topSitesMenuAction(fn: () => void) { + return () => { + fn() + setShowTopSitesMenu(false) + } + } + + function onTileDrop(position: number) { + return (url: string, location: DropLocation) => { + const current = topSites.findIndex((item) => item.url === url) + if (current < 0) { + return + } + let index = position; + if (location === 'after' && index < current) { + index += 1 + } + model.setTopSitePosition(url, index) + } + } + + function maybeCollapseOnClick(event: React.MouseEvent) { + const { target } = event + if (expanded && target instanceof HTMLElement) { + if (target.classList.contains('collapse-on-click')) { + setExpanded(false) + } + } + } + + return ( +
+
+ setExpanded(false)} + > +
+ + setShowTopSitesMenu(false)} + > +
+ { + listKind === 'custom' ? + : + + } + +
+
+
+
+ { + topSites.map((topSite, i) => { + if (i > maxTileCount) { + return null + } + return ( + + ) + }) + } + { + showAddButton && + + } +
+
+ + setContextMenuSite(null)} + > +
+ { + listKind === 'custom' && + + } + +
+
+ { + if (editSite) { + model.updateTopSite(editSite.url, url, title) + } else { + model.addTopSite(url, title) + } + setShowEditSite(false) + }} + onClose={() => { setShowEditSite(false)}} + /> + { + model.undoRemoveTopSite() + setShowRemoveToast(false) + }} + onClose={() => { + setShowRemoveToast(false) + }} + /> + +
+ ) +} diff --git a/browser/resources/brave_new_tab/components/widgets/ntp_widget.style.ts b/browser/resources/brave_new_tab/components/widgets/ntp_widget.style.ts new file mode 100644 index 000000000000..48c23fbc4c81 --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/ntp_widget.style.ts @@ -0,0 +1,20 @@ +/* Copyright (c) 2025 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 } from '@brave/leo/tokens/css/variables' +import { scoped } from '../../lib/scoped_css' + +export const style = scoped.css` + & { + flex: 0 1 420px; + color: ${color.text.primary}; + border-radius: 16px; + background: rgba(0, 0, 0, 0.50); + backdrop-filter: blur(50px); + display: flex; + align-items: stretch; + overflow: clip; + } +` diff --git a/browser/resources/brave_new_tab/components/widgets/ntp_widget.tsx b/browser/resources/brave_new_tab/components/widgets/ntp_widget.tsx new file mode 100644 index 000000000000..178cf2331f36 --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/ntp_widget.tsx @@ -0,0 +1,20 @@ +/* Copyright (c) 2025 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 { style } from './ntp_widget.style' + +interface Props { + children: React.ReactNode +} + +export function NtpWidget(props: Props) { + return ( +
+ {props.children} +
+ ) +} diff --git a/browser/resources/brave_new_tab/components/widgets/product_widget_stack.style.ts b/browser/resources/brave_new_tab/components/widgets/product_widget_stack.style.ts new file mode 100644 index 000000000000..088befdae011 --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/product_widget_stack.style.ts @@ -0,0 +1,41 @@ +/* Copyright (c) 2025 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 } from '@brave/leo/tokens/css/variables' +import { scoped } from '../../lib/scoped_css' + +export const style = scoped.css` + & { + flex-grow: 1; + display: flex; + align-items: stretch; + } + + .stack-tabs { + --leo-icon-size: 16px; + --leo-icon-color: ${color.icon.default}; + + display: flex; + flex-direction: column; + + > * { + background: rgba(255, 255, 255, 0.10); + flex: 1 1 auto; + padding: 0 16px; + display: flex; + align-items: center; + } + + .active { + --leo-icon-color: #fff; + background: inherit; + } + } + + .widget { + flex: 1 1 auto; + padding: 16px; + } +` diff --git a/browser/resources/brave_new_tab/components/widgets/product_widget_stack.tsx b/browser/resources/brave_new_tab/components/widgets/product_widget_stack.tsx new file mode 100644 index 000000000000..71f71a1589a4 --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/product_widget_stack.tsx @@ -0,0 +1,116 @@ +/* Copyright (c) 2025 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 { useNewTabState } from '../context/new_tab_context' +import { useRewardsState } from '../context/rewards_context' +import { useVPNState } from '../context/vpn_context' +import { NtpWidget } from './ntp_widget' +import { RewardsWidget } from './rewards_widget' +import { TalkWidget } from './talk_widget' +import { VPNWidget } from './vpn_widget' + +import { style } from './product_widget_stack.style' + +type TabName = 'vpn' | 'rewards' | 'talk' + +const tabList: TabName[] = ['vpn', 'rewards', 'talk'] + +function loadCurrentTab(): TabName | null { + const value = localStorage.getItem('ntp-current-product-widget') + switch (value) { + case 'vpn': + case 'rewards': + case 'talk': + return value + default: + return null + } +} + +function storeCurrentTab(tab: TabName | null) { + localStorage.setItem('ntp-current-product-widget', tab ?? '') +} + +export function ProductWidgetStack() { + const [vpnFeatureEnabled, showVpnWidget] = useVPNState((state) => [ + state.vpnFeatureEnabled, + state.showVpnWidget + ]) + + const showRewardsWidget = useRewardsState((state) => state.showRewardsWidget) + const showTalkWidget = useNewTabState((state) => state.showTalkWidget) + + const [currentTab, setCurrentTab] = React.useState(loadCurrentTab()) + + const visibleTabs = React.useMemo(() => { + return tabList.filter((tab) => { + switch (tab) { + case 'rewards': return showRewardsWidget + case 'talk': return showTalkWidget + case 'vpn': return vpnFeatureEnabled && showVpnWidget + } + }) + }, [vpnFeatureEnabled, showVpnWidget, showTalkWidget, showRewardsWidget]) + + React.useEffect(() => { + storeCurrentTab(currentTab) + if (currentTab && !visibleTabs.includes(currentTab)) { + setCurrentTab(null) + } + }, [currentTab, visibleTabs]) + + if (visibleTabs.length === 0) { + return null + } + + const activeTab = currentTab || visibleTabs[0] + + function renderProductIcon(tab: TabName) { + switch (tab) { + case 'rewards': return + case 'talk': return + case 'vpn': return + } + } + + function renderTabButton(tab: TabName) { + return ( + + ) + } + + function renderWidget() { + switch (activeTab) { + case 'vpn': return + case 'rewards': return + case 'talk': return + } + } + + return ( + +
+ { + visibleTabs.length > 1 && +
+ {visibleTabs.map(renderTabButton)} +
+ } +
+ {renderWidget()} +
+
+
+ ) +} diff --git a/browser/resources/brave_new_tab/components/widgets/rewards_widget.style.ts b/browser/resources/brave_new_tab/components/widgets/rewards_widget.style.ts new file mode 100644 index 000000000000..fdc8f3d7f2ce --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/rewards_widget.style.ts @@ -0,0 +1,85 @@ +/* Copyright (c) 2025 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' + +import rewardsBatCoinURL from '../../assets/rewards_bat_coin.svg' + +export const style = scoped.css` + & { + display: flex; + flex-direction: column; + gap: 8px; + } + + .title { + --leo-icon-size: 16px; + font: ${font.components.buttonSmall}; + display: flex; + align-items: center; + gap: 8px; + } + + .content { + display: flex; + gap: 12px; + align-items: center; + } + + .text { + flex: 1 1 auto; + } + + &.onboarding { + .text { + --leo-icon-size: 12px; + --leo-icon-color: ${color.icon.disabled}; + + font: ${font.xSmall.regular}; + display: flex; + flex-direction: column; + gap: 4px; + + > * { + display: flex; + align-items: center; + gap: 8px; + } + } + } + + .coin-graphic { + height: 62px; + width: 62px; + background-image: url(${rewardsBatCoinURL}); + background-position: left center; + background-size: cover; + } + + &.connected .text { + flex: 1 1 auto; + font: ${font.small.regular}; + } + + .actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + + leo-button { + --leo-button-color: rgba(255, 255, 255, 0.2); + white-space: nowrap; + flex-grow: 0; + } + + a { + font: ${font.xSmall.link}; + text-decoration: underline; + color: rgba(255, 255, 255, .5); + } + } +` diff --git a/browser/resources/brave_new_tab/components/widgets/rewards_widget.tsx b/browser/resources/brave_new_tab/components/widgets/rewards_widget.tsx new file mode 100644 index 000000000000..fa7ee658d667 --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/rewards_widget.tsx @@ -0,0 +1,105 @@ +/* Copyright (c) 2025 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 { useLocale } from '../context/locale_context' +import { useRewardsState } from '../context/rewards_context' +import { Link, openLink } from '../link' + +import * as urls from '../../../../../components/brave_rewards/resources/shared/lib/rewards_urls' + +import { style } from './rewards_widget.style' + +export function RewardsWidget() { + const { getString } = useLocale() + + const [ + rewardsEnabled, + externalWallet + ] = useRewardsState((state) => [ + state.rewardsEnabled, + state.externalWallet + ]) + + function renderOnboarding() { + return ( +
+
+ {getString('rewardsWidgetTitle')} +
+
+
+
+ +
{getString('rewardsFeatureText1')}
+
+
+ +
{getString('rewardsFeatureText2')}
+
+
+
+ + + {getString('rewardsOnboardingLink')} + +
+
+
+ ) + } + + function renderUnconnected() { + return ( +
+
+ {getString('rewardsWidgetTitle')} +
+
+
+
+
+
+ +
+
+
+ ) + } + + if (!rewardsEnabled) { + return renderOnboarding() + } + + if (!externalWallet) { + return renderUnconnected() + } + + return ( +
+
+ {getString('rewardsWidgetTitle')} +
+
+
+
+
+
+
+ ) +} diff --git a/browser/resources/brave_new_tab/components/widgets/stats_widget.style.ts b/browser/resources/brave_new_tab/components/widgets/stats_widget.style.ts new file mode 100644 index 000000000000..787020f754df --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/stats_widget.style.ts @@ -0,0 +1,46 @@ +/* Copyright (c) 2025 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` + & { + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + } + + .title { + font: ${font.components.buttonSmall}; + } + + .data { + display: flex; + align-items: top; + gap: 24px; + color: ${color.primitive.neutral[90]}; + font: ${font.xSmall.regular}; + } + + .ads-blocked { + --self-value-color: ${color.primitive.orange[70]}; + } + + .bandwidth-saved { + --self-value-color: ${color.primitive.primary[70]}; + } + + .value { + color: var(--self-value-color, #fff); + font: ${font.heading.h3}; + } + + .units { + font: ${font.default.semibold}; + text-transform: capitalize; + } +` diff --git a/browser/resources/brave_new_tab/components/widgets/stats_widget.tsx b/browser/resources/brave_new_tab/components/widgets/stats_widget.tsx new file mode 100644 index 000000000000..de23e72dc77f --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/stats_widget.tsx @@ -0,0 +1,139 @@ +/* Copyright (c) 2025 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 { useNewTabState } from '../context/new_tab_context' +import { useLocale } from '../context/locale_context' +import { NtpWidget } from './ntp_widget' + +import { style } from './stats_widget.style' + +const adsBlockedFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 0, + useGrouping: true +}) + +function getTimeSaved(adsBlocked: number) { + return adsBlocked * 50 +} + +function formatTimeInterval( + value: number, + unit: 'day' | 'hour' | 'minute' | 'second', + maximumFractionDigits: number = 0 +) { + return new Intl.NumberFormat(undefined, { + style: 'unit', + unit, + unitDisplay: 'long', + maximumFractionDigits, + roundingMode: 'ceil' + }).formatToParts(value) +} + +function formatTimeSaved(ms: number) { + const seconds = ms / 1000 + const minutes = seconds / 60 + const hours = minutes / 60 + const days = hours / 24 + + if (days >= 1) { + return formatTimeInterval(days, 'day', 2) + } + if (hours >= 1) { + return formatTimeInterval(hours, 'hour', 1) + } + if (minutes >= 1) { + return formatTimeInterval(minutes, 'minute') + } + if (seconds >= 1) { + return formatTimeInterval(seconds, 'second') + } + return formatTimeInterval(0, 'second') +} + +function formatMemoryValue( + value: number, + unit: 'kilobyte' | 'megabyte' | 'gigabyte', + maximumFractionDigits: number = 0 +) { + return new Intl.NumberFormat(undefined, { + style: 'unit', + unit, + unitDisplay: 'short', + maximumFractionDigits, + roundingMode: 'ceil' + }).formatToParts(value) +} + +function formatBandwidth(bytes: number) { + const kb = bytes / 1024 + const mb = kb / 1024 + const gb = mb / 1024 + + if (gb >= 1) { + return formatMemoryValue(gb, 'gigabyte', 2) + } + if (mb >= 1) { + return formatMemoryValue(mb, 'megabyte', 1) + } + if (kb >= 1) { + return formatMemoryValue(kb, 'kilobyte') + } + return formatMemoryValue(kb, 'kilobyte') +} + +export function StatsWidget() { + const { getString } = useLocale() + const [showStats, stats] = useNewTabState((state) => [ + state.showShieldsStats, + state.shieldsStats + ]) + + if (!showStats) { + return null + } + + function renderUnits(parts: Intl.NumberFormatPart[]) { + return parts.map(({ type, value }) => { + if (type === 'unit') { + return {value} + } + return value + }) + } + + return ( + +
+
+ {getString('statsTitle')} +
+
+
+
+ {stats && adsBlockedFormatter.format(stats.adsBlocked)} +
+ {getString('statsAdsBlockedText')} +
+
+
+ {stats && renderUnits(formatBandwidth(stats.bandwidthSavedBytes))} +
+ {getString('statsBandwidthSavedText')} +
+
+
+ {stats && + renderUnits(formatTimeSaved(getTimeSaved(stats.adsBlocked)))} +
+ {getString('statsTimeSavedText')} +
+
+
+
+ ) +} diff --git a/browser/resources/brave_new_tab/components/widgets/talk_widget.style.ts b/browser/resources/brave_new_tab/components/widgets/talk_widget.style.ts new file mode 100644 index 000000000000..7869036ae2f0 --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/talk_widget.style.ts @@ -0,0 +1,59 @@ +/* Copyright (c) 2025 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' + +import talkGraphic from '../../assets/talk_graphic.svg' + +export const style = scoped.css` + & { + display: flex; + flex-direction: column; + gap: 8px; + } + + .title { + font: ${font.components.buttonSmall}; + } + + .content { + display: flex; + align-items: center; + } + + .graphic { + background-image: url(${talkGraphic}); + background-repeat: no-repeat; + background-size: contain; + background-position: center center; + width: 56px; + align-self: stretch; + } + + .text { + flex: 1 1 auto; + display: flex; + flex-direction: column; + gap: 4px; + font: ${font.small.regular}; + color: ${color.text.tertiary}; + padding: 0 8px; + } + + .header { + font: ${font.default.semibold}; + color: ${color.text.primary}; + } + + .actions { + padding: 0 8px; + + leo-button { + --leo-button-color: rgba(255, 255, 255, 0.20); + white-space: nowrap; + } + } +` diff --git a/browser/resources/brave_new_tab/components/widgets/talk_widget.tsx b/browser/resources/brave_new_tab/components/widgets/talk_widget.tsx new file mode 100644 index 000000000000..0478a852be10 --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/talk_widget.tsx @@ -0,0 +1,42 @@ +/* Copyright (c) 2025 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 { useLocale } from '../context/locale_context' +import { openLink } from '../link' + +import { style } from './talk_widget.style' + +export function TalkWidget() { + const { getString } = useLocale() + return ( +
+
+ {getString('talkWidgetTitle')} +
+
+
+
+
+ {getString('talkDescriptionTitle')} +
+
+ {getString('talkDescriptionText')} +
+
+
+ +
+
+
+ ) +} diff --git a/browser/resources/brave_new_tab/components/widgets/vpn_widget.style.ts b/browser/resources/brave_new_tab/components/widgets/vpn_widget.style.ts new file mode 100644 index 000000000000..936ff20878f4 --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/vpn_widget.style.ts @@ -0,0 +1,135 @@ +/* Copyright (c) 2025 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, gradient } from '@brave/leo/tokens/css/variables' +import { scoped } from '../../lib/scoped_css' + +import guardianLogoURL from '../../assets/guardian_vpn_logo.svg' +import vpnShieldDisconnectedURL from '../../assets/vpn_shield_disconnected.svg' +import vpnShieldConnectedURL from '../../assets/vpn_shield_connected.svg' + +export { guardianLogoURL, vpnShieldConnectedURL, vpnShieldDisconnectedURL } + +export const style = scoped.css` + & { + display: flex; + flex-direction: column; + gap: 8px; + } + + .title { + --leo-icon-size: 16px; + --leo-icon-color: ${gradient.iconsActive}; + + font: ${font.components.buttonSmall}; + display: flex; + justify-content: space-between; + + > * { + display: flex; + gap: 8px; + align-items: center; + } + } + + .provider { + opacity: 0.3; + color: #fff; + font: ${font.xSmall.regular}; + display: flex; + align-items: center; + gap: 4px; + + img { + height: 12px; + width: auto; + } + } + + .content { + display: flex; + gap: 12px; + } + + .features { + --leo-icon-size: 12px; + --leo-icon-color: ${color.icon.disabled}; + + flex: 1 1 auto; + font: ${font.xSmall.regular}; + display: flex; + flex-direction: column; + gap: 4px; + + > * { + display: flex; + align-items: center; + gap: 8px; + } + } + + .purchase-actions { + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + + leo-button { + --leo-button-color: rgba(255, 255, 255, 0.20); + } + + .restore { + opacity: 0.5; + color: #fff; + font: ${font.xSmall.regular}; + } + } + + .connection-graphic { + min-width: 62px; + + img { + height: 62px; + width: auto; + } + } + + .status { + font: ${font.small.regular}; + color: ${color.text.tertiary}; + margin-bottom: 4px; + } + + .connected .status { + color: #5FDA5C; + } + + .connection-info { + flex: 1 1 auto; + } + + .region { + font: ${font.xSmall.regular}; + color: ${color.text.secondary}; + } + + .country { + display: flex; + align-items: center; + gap: 8px; + font: ${font.large.semibold}; + color: ${color.text.primary}; + + button { + font: ${font.xSmall.link}; + text-decoration: underline; + } + } + + .connection-toggle { + margin-top: 16px; + } + +` diff --git a/browser/resources/brave_new_tab/components/widgets/vpn_widget.tsx b/browser/resources/brave_new_tab/components/widgets/vpn_widget.tsx new file mode 100644 index 000000000000..c1fd160c51cb --- /dev/null +++ b/browser/resources/brave_new_tab/components/widgets/vpn_widget.tsx @@ -0,0 +1,144 @@ +/* Copyright (c) 2025 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 Toggle from '@brave/leo/react/toggle' + +import { useVPNModel, useVPNState } from '../context/vpn_context' +import { useLocale } from '../context/locale_context' + +import { + style, + guardianLogoURL, + vpnShieldConnectedURL, + vpnShieldDisconnectedURL } from './vpn_widget.style' + +export function VPNWidget() { + const { getString } = useLocale() + const vpnModel = useVPNModel() + + const [purchased, connectionState, region] = useVPNState((state) => [ + state.purchased, + state.connectionState, + state.connectionRegion + ]) + + function connectionStateText() { + switch (connectionState) { + case 'connected': return getString('vpnStatusConnected') + case 'disconnected': return getString('vpnStatusDisconnected') + case 'connecting': return getString('vpnStatusConnecting') + case 'disconnecting': return getString('vpnStatusDisconnecting') + } + } + + function renderPromo() { + return ( +
+
+ + + {getString('vpnWidgetTitle')} + + + {getString('vpnPoweredByText')} + +
+
+
+
+ + {getString('vpnFeatureText1')} +
+
+ + {getString('vpnFeatureText2')} +
+
+ + {getString('vpnFeatureText3')} +
+
+
+ + +
+
+
+ ) + } + + function renderConnectionGraphic() { + const image = connectionState === 'connected' + ? vpnShieldConnectedURL + : vpnShieldDisconnectedURL + + return + } + + function renderRegionInfo() { + if (!region) { + return null + } + + return ( +
+
+ {region.country} + +
+
+ { + region.name === region.country + ? getString('vpnOptimalText') + : region.name + } +
+
+ ) + } + + if (!purchased) { + return renderPromo() + } + + return ( +
+
{getString('vpnWidgetTitle')}
+
+
+ {renderConnectionGraphic()} +
+
+
{connectionStateText()}
+ {renderRegionInfo()} +
+
+ vpnModel.toggleConnection()} + /> +
+
+
+ ) +} diff --git a/browser/resources/brave_new_tab/lib/favicon_url.ts b/browser/resources/brave_new_tab/lib/favicon_url.ts new file mode 100644 index 000000000000..1e4afb96144e --- /dev/null +++ b/browser/resources/brave_new_tab/lib/favicon_url.ts @@ -0,0 +1,8 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/lib/image_loader.ts b/browser/resources/brave_new_tab/lib/image_loader.ts new file mode 100644 index 000000000000..a18fc1bcce85 --- /dev/null +++ b/browser/resources/brave_new_tab/lib/image_loader.ts @@ -0,0 +1,38 @@ +/* Copyright (c) 2025 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 placeholderImageSrc = + 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg"%3E%3C/svg%3E' + +// Loads an image in the background and resolves when the image has either +// loaded or was unable to load. +export function loadImage(url: string): Promise { + return new Promise((resolve) => { + if (!url) { + resolve(false) + return + } + + const unlisten = () => { + image.removeEventListener('load', onLoad) + image.removeEventListener('error', onError) + } + + const onLoad = () => { + unlisten() + resolve(true) + } + + const onError = () => { + unlisten() + resolve(false) + } + + const image = new Image() + image.addEventListener('load', onLoad) + image.addEventListener('error', onError) + image.src = url + }) +} diff --git a/browser/resources/brave_new_tab/lib/inline_css_vars.ts b/browser/resources/brave_new_tab/lib/inline_css_vars.ts new file mode 100644 index 000000000000..47bdd38f971c --- /dev/null +++ b/browser/resources/brave_new_tab/lib/inline_css_vars.ts @@ -0,0 +1,14 @@ +/* Copyright (c) 2025 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/. */ + +interface CSSVars { + [key: `--${string}`]: string | number +} + +// Allows a collection of CSS custom variables to be used in the "style" prop of +// React components. +export function inlineCSSVars(vars: CSSVars) { + return vars as React.CSSProperties +} diff --git a/browser/resources/brave_new_tab/lib/locale_strings.ts b/browser/resources/brave_new_tab/lib/locale_strings.ts new file mode 100644 index 000000000000..b1b0789ad78c --- /dev/null +++ b/browser/resources/brave_new_tab/lib/locale_strings.ts @@ -0,0 +1,93 @@ +/* Copyright (c) 2025 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 type StringKey = + 'addTopSiteLabel' | + 'addTopSiteTitle' | + 'backgroundSettingsTitle' | + 'braveBackgroundLabel' | + 'cancelButtonLabel' | + 'clockFormatLabel' | + 'clockFormatOption12HourText' | + 'clockFormatOption24HourText' | + 'clockFormatOptionAutomaticText' | + 'clockSettingsTitle' | + 'customBackgroundLabel' | + 'customBackgroundTitle' | + 'customizeSearchEnginesLink' | + 'editTopSiteLabel' | + 'editTopSiteTitle' | + 'enabledSearchEnginesLabel' | + 'gradientBackgroundLabel' | + 'gradientBackgroundTitle' | + 'hideTopSitesLabel' | + 'photoCreditsText' | + 'randomizeBackgroundLabel' | + 'removeTopSiteLabel' | + 'rewardsConnectButtonLabel' | + 'rewardsFeatureText1' | + 'rewardsFeatureText2' | + 'rewardsOnboardingButtonLabel' | + 'rewardsOnboardingLink' | + 'rewardsWidgetTitle' | + 'saveChangesButtonLabel' | + 'searchAskLeoDescription' | + 'searchBoxPlaceholderText' | + 'searchBoxPlaceholderTextBrave' | + 'searchCustomizeEngineListText' | + 'searchSettingsTitle' | + 'searchSuggestionsDismissButtonLabel' | + 'searchSuggestionsEnableButtonLabel' | + 'searchSuggestionsPromptText' | + 'searchSuggestionsPromptTitle' | + 'settingsTitle' | + 'showBackgroundsLabel' | + 'showClockLabel' | + 'showRewardsWidgetLabel' | + 'showSearchBoxLabel' | + 'showSponsoredImagesLabel' | + 'showStatsLabel' | + 'showTalkWidgetLabel' | + 'showTopSitesLabel' | + 'showVpnWidgetLabel' | + 'solidBackgroundLabel' | + 'solidBackgroundTitle' | + 'statsAdsBlockedText' | + 'statsBandwidthSavedText' | + 'statsSettingsTitle' | + 'statsTimeSavedText' | + 'statsTitle' | + 'talkDescriptionText' | + 'talkDescriptionTitle' | + 'talkStartCallLabel' | + 'talkWidgetTitle' | + 'topSiteRemovedText' | + 'topSiteRemovedTitle' | + 'topSitesCustomOptionText' | + 'topSitesCustomOptionTitle' | + 'topSitesMostVisitedOptionText' | + 'topSitesMostVisitedOptionTitle' | + 'topSitesSettingsTitle' | + 'topSitesShowCustomLabel' | + 'topSitesShowMostVisitedLabel' | + 'topSitesTitleLabel' | + 'topSitesURLLabel' | + 'undoButtonLabel' | + 'uploadBackgroundLabel' | + 'vpnChangeRegionLabel' | + 'vpnFeatureText1' | + 'vpnFeatureText2' | + 'vpnFeatureText3' | + 'vpnOptimalText' | + 'vpnPoweredByText' | + 'vpnRestorePurchaseLabel' | + 'vpnStartTrialLabel' | + 'vpnStatusConnected' | + 'vpnStatusConnecting' | + 'vpnStatusDisconnected' | + 'vpnStatusDisconnecting' | + 'vpnWidgetTitle' | + 'widgetLayoutLabel' | + 'widgetSettingsTitle' diff --git a/browser/resources/brave_new_tab/lib/optional.ts b/browser/resources/brave_new_tab/lib/optional.ts new file mode 100644 index 000000000000..367f45fc6e13 --- /dev/null +++ b/browser/resources/brave_new_tab/lib/optional.ts @@ -0,0 +1,31 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/lib/scoped_css.ts b/browser/resources/brave_new_tab/lib/scoped_css.ts new file mode 100644 index 000000000000..a2cf55fbfcf4 --- /dev/null +++ b/browser/resources/brave_new_tab/lib/scoped_css.ts @@ -0,0 +1,58 @@ +/* Copyright (c) 2025 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/. */ + +// Adds CSS to the document. If a stylesheet with the specified `id` has already +// been added to the document, then it will be replaced with the provided CSS. +async function addStyles(cssText: unknown) { + const stylesheet = new CSSStyleSheet() + document.adoptedStyleSheets.push(stylesheet) + await stylesheet.replace(String(cssText)) +} + +const scopeAttributeName = 'data-css-scope' + +class ScopedCSSAttribute { + [scopeAttributeName]: string + + constructor(scopeName: string) { + this[scopeAttributeName] = scopeName + } + + get scope() { + return this[scopeAttributeName] + } + + get selector() { + return `[${scopeAttributeName}=${CSS.escape(this[scopeAttributeName])}]` + } +} + +let nextScopeID = 0x5c09ed; + +// A template tag that adds scoped CSS to the document. The provided CSS text +// is wrapped with a "@scope" at-rule and only applies to elements with a +// "data-css-scope" attribute whose value matches `scopeName`. The CSS rules do +// not apply to any descendant elements that have a "data-css-scope" attribute. +// Returns an object representing the CSS scope data attribute, which can be +// object-spread into a collection of HTML attributes. +export const scoped = { + css(callsite: TemplateStringsArray, ...values: any[]) { + const id = (nextScopeID++).toString(36) + const attr = new ScopedCSSAttribute(id) + addStyles(` + @scope (${attr.selector}) to ([${scopeAttributeName}]) { + ${String.raw(callsite, ...values)} + } + `) + return attr + } +} + +// Adds global CSS to the document. +export const global = { + css(callsite: TemplateStringsArray, ...values: any[]) { + addStyles(String.raw(callsite, ...values)) + } +} diff --git a/browser/resources/brave_new_tab/lib/store.ts b/browser/resources/brave_new_tab/lib/store.ts new file mode 100644 index 000000000000..93113ebcbb3c --- /dev/null +++ b/browser/resources/brave_new_tab/lib/store.ts @@ -0,0 +1,93 @@ +/* Copyright (c) 2025 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/. */ + +type Listener = (state: State) => void + +type UpdateFunction = (state: State) => Partial + +// A simple object-state store. +export interface Store { + + // Returns the current state of the store. + getState: () => State + + // Updates the state of the store. All listeners will be notified of the state + // change in a future turn of the event loop. Listeners will not be notified + // if there was no change to state, as determined by a strict-equality test. + update: (source: Partial | UpdateFunction) => void + + // Adds a listener that will be notified when the state store changes. The + // listener will not be notified immediately. Returns a function that will + // remove the listener from store. + addListener: (listener: Listener) => (() => void) + +} + +export function createStore(initialState: State): Store { + const listeners = new Set>() + const state = { ...initialState } + let notificationQueued = false + + function notify() { + if (notificationQueued) { + return + } + + notificationQueued = true + + // Send update notifications in a future turn in order to avoid reentrancy. + queueMicrotask(() => { + notificationQueued = false + for (const listener of listeners) { + // If a notification has been queued as a result of calling a listener, + // then exit this notification. The next update will be sent in a future + // turn to all remaining listeners. + if (notificationQueued) { + break + } + try { + listener(state) + } catch (e) { + // Rethrow error in a future turn to prevent listeners from + // interfering with each other. + queueMicrotask(() => { throw e }) + } + } + }) + } + + return { + + getState() { + return state + }, + + update(source: Partial | UpdateFunction) { + if (typeof source === 'function') { + source = source(state) + } + let didAssign = false + for (const [key, value] of Object.entries(source)) { + if (value !== undefined && (state as any)[key] !== value) { + (state as any)[key] = value + didAssign = true + } + } + if (didAssign) { + notify() + } + }, + + addListener(listener: Listener) { + if (!listeners.has(listener)) { + listeners.add(listener) + } + return () => { + listeners.delete(listener) + } + } + + } +} diff --git a/browser/resources/brave_new_tab/lib/url_input.ts b/browser/resources/brave_new_tab/lib/url_input.ts new file mode 100644 index 000000000000..1444cdb96a3c --- /dev/null +++ b/browser/resources/brave_new_tab/lib/url_input.ts @@ -0,0 +1,27 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/lib/url_sanitizer.ts b/browser/resources/brave_new_tab/lib/url_sanitizer.ts new file mode 100644 index 000000000000..8a4cffbe701c --- /dev/null +++ b/browser/resources/brave_new_tab/lib/url_sanitizer.ts @@ -0,0 +1,19 @@ +/* Copyright (c) 2025 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/. */ + +const allowedProtocols = new Set(['https:', 'chrome:']) + +export function sanitizeExternalURL(urlString: string) { + let url: URL | null = null + try { + url = new URL(urlString) + } catch { + return '' + } + if (!allowedProtocols.has(url.protocol)) { + return '' + } + return url.toString() +} diff --git a/browser/resources/brave_new_tab/lib/use_model_state.ts b/browser/resources/brave_new_tab/lib/use_model_state.ts new file mode 100644 index 000000000000..fdfa3cbe2737 --- /dev/null +++ b/browser/resources/brave_new_tab/lib/use_model_state.ts @@ -0,0 +1,35 @@ +/* Copyright (c) 2025 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' + +type StateListener = (state: State) => void + +interface Model { + getState: () => State + addListener: (listener: StateListener) => () => void +} + +// A helper for importing model state into component state. This helper can be +// used by model context modules when implementing "useFooState" hooks. +export function useModelState( + model: Model, + map: (state: State) => T +): T { + const [value, setValue] = React.useState(() => map(model.getState())) + React.useEffect(() => { + return model.addListener((state) => { + const result = map(state) + if (Object.is(result, state)) { + // If `map` is the identity function, then call `setState` with a new + // object in order to ensure a re-render. + setValue({ ...result }) + } else { + setValue(result) + } + }) + }, [model]) + return value +} diff --git a/browser/resources/brave_new_tab/models/backgrounds.ts b/browser/resources/brave_new_tab/models/backgrounds.ts new file mode 100644 index 000000000000..4f0217b62012 --- /dev/null +++ b/browser/resources/brave_new_tab/models/backgrounds.ts @@ -0,0 +1,99 @@ +/* Copyright (c) 2025 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 { NewTabState, Background, BackgroundType } from './new_tab_model' + +export const solidBackgrounds = [ + '#5B5C63', '#000000', '#151E9A', '#2197F9', '#1FC3DC', '#086582', '#67D4B4', + '#077D5A', '#3C790B', '#AFCE57', '#F0CB44', '#F28A29', '#FC798F', '#C1226E', + '#FAB5EE', '#C0C4FF', '#9677EE', '#5433B0', '#4A000C' +] + +export const solidPreviewBackground = solidBackgrounds[2] + +export const gradientBackgrounds = [ + 'linear-gradient(125.83deg, #392DD1 0%, #A91B78 99.09%)', + 'linear-gradient(125.83deg, #392DD1 0%, #22B8CF 99.09%)', + 'linear-gradient(90deg, #4F30AB 0.64%, #845EF7 99.36%)', + 'linear-gradient(126.47deg, #A43CE4 16.99%, #A72B6D 86.15%)', + 'radial-gradient(' + + '69.45% 69.45% at 89.46% 81.73%, #641E0C 0%, #500F39 43.54%, #060141 100%)', + 'radial-gradient(80% 80% at 101.61% 76.99%, #2D0264 0%, #030023 100%)', + 'linear-gradient(128.12deg, #43D4D4 6.66%, #1596A9 83.35%)', + 'linear-gradient(323.02deg, #DD7131 18.65%, #FBD460 82.73%)', + 'linear-gradient(128.12deg, #4F86E2 6.66%, #694CD9 83.35%)', + 'linear-gradient(127.39deg, #851B6A 6.04%, #C83553 86.97%)', + 'linear-gradient(130.39deg, #FE6F4C 9.83%, #C53646 85.25%)' +] + +export const gradientPreviewBackground = gradientBackgrounds[0] + +const defaultBackground: Background = { + type: 'gradient', + cssValue: gradientPreviewBackground +} + +function chooseRandom(list: T[]): T | null { + if (list.length === 0) { + return null + } + return list[Math.floor(Math.random() * list.length)] +} + +export function getCurrentBackground(state: NewTabState): Background | null { + const { + backgroundsEnabled, + braveBackgrounds, + customBackgrounds, + selectedBackground, + selectedBackgroundType, + sponsoredImageBackground, + currentBackground } = state + + if (!backgroundsEnabled) { + return defaultBackground + } + + if (sponsoredImageBackground) { + return sponsoredImageBackground + } + + if (currentBackground && + selectedBackgroundType === currentBackground.type && + !selectedBackground) { + return currentBackground + } + + switch (selectedBackgroundType) { + case 'brave': { + return chooseRandom(braveBackgrounds) + } + case 'custom': { + const imageUrl = selectedBackground || chooseRandom(customBackgrounds) + return imageUrl ? { type: 'custom', imageUrl } : null + } + case 'solid': { + const cssValue = selectedBackground || chooseRandom(solidBackgrounds) + return cssValue ? { type: 'solid', cssValue } : null + } + case 'gradient': { + const cssValue = selectedBackground || chooseRandom(gradientBackgrounds) + return cssValue ? { type: 'gradient', cssValue } : null + } + case 'none': { + return defaultBackground + } + } +} + +export function backgroundCSSValue(type: BackgroundType, value: string) { + switch (type) { + case 'brave': + case 'custom': return `url(${CSS.escape(value)})` + case 'solid': + case 'gradient': + case 'none': return value + } +} diff --git a/browser/resources/brave_new_tab/models/new_tab_model.ts b/browser/resources/brave_new_tab/models/new_tab_model.ts new file mode 100644 index 000000000000..46bf8ba282ab --- /dev/null +++ b/browser/resources/brave_new_tab/models/new_tab_model.ts @@ -0,0 +1,125 @@ +/* Copyright (c) 2025 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 type BackgroundType = 'brave' | 'custom' | 'solid' | 'gradient' | 'none' + +export interface BraveBackground { + type: 'brave' + author: string + imageUrl: string + link: string +} + +export interface ColorBackground { + type: 'solid' | 'gradient' + cssValue: string +} + +export interface CustomBackground { + type: 'custom' + imageUrl: string +} + +export interface SponsoredImageLogo { + alt: string + destinationUrl: string + imageUrl: string +} + +export interface SponsoredImageBackground { + type: 'sponsored' + imageUrl: string, + creativeInstanceId: string + wallpaperId: string + logo: SponsoredImageLogo | undefined +} + +export type Background = + BraveBackground | + ColorBackground | + CustomBackground | + SponsoredImageBackground + +export type ClockFormat = '' | 'h12' | 'h24' + +export interface ShieldsStats { + bandwidthSavedBytes: number + adsBlocked: number +} + +export type WidgetPosition = 'top' | 'bottom' + +export interface NewTabState { + backgroundsEnabled: boolean + backgroundsCustomizable: boolean + sponsoredImagesEnabled: boolean + braveBackgrounds: BraveBackground[] + customBackgrounds: string[] + selectedBackgroundType: BackgroundType + selectedBackground: string + currentBackground: Background | null + sponsoredImageBackground: SponsoredImageBackground | null + showClock: boolean + clockFormat: ClockFormat + showShieldsStats: boolean + shieldsStats: ShieldsStats | null + showTalkWidget: boolean + widgetPosition: WidgetPosition +} + +export function defaultState(): NewTabState { + return { + backgroundsEnabled: true, + backgroundsCustomizable: true, + sponsoredImagesEnabled: true, + braveBackgrounds: [], + customBackgrounds: [], + selectedBackgroundType: 'none', + selectedBackground: '', + currentBackground: null, + sponsoredImageBackground: null, + showClock: false, + clockFormat: '', + showShieldsStats: false, + shieldsStats: null, + showTalkWidget: true, + widgetPosition: 'bottom' + } +} + +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 + showCustomBackgroundChooser: () => Promise + removeCustomBackground: (background: string) => Promise + setShowClock: (showClock: boolean) => void + setClockFormat: (format: ClockFormat) => void + setShowShieldsStats: (showShieldsStats: boolean) => void + setShowTalkWidget: (showTalkWidget: boolean) => void + setWidgetPosition: (widgetPosition: WidgetPosition) => void +} + +export function defaultModel(): NewTabModel { + const state = defaultState() + return { + getState() { return state }, + addListener() { return () => {} }, + async getPcdnImageURL(url) { return url }, + setBackgroundsEnabled(enabled) {}, + setSponsoredImagesEnabled(enabled) {}, + selectBackground(type, value) {}, + async showCustomBackgroundChooser() { return false }, + async removeCustomBackground(background) {}, + setShowClock(showClock) {}, + setClockFormat(format) {}, + setShowShieldsStats(showShieldsStats) {}, + setShowTalkWidget(showTalkWidget) {}, + setWidgetPosition(widgetPosition: WidgetPosition) {} + } +} diff --git a/browser/resources/brave_new_tab/models/rewards_model.ts b/browser/resources/brave_new_tab/models/rewards_model.ts new file mode 100644 index 000000000000..7ee65f7453f9 --- /dev/null +++ b/browser/resources/brave_new_tab/models/rewards_model.ts @@ -0,0 +1,37 @@ +/* Copyright (c) 2025 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 { ExternalWallet } from '../../../../components/brave_rewards/resources/shared/lib/external_wallet' + +export interface RewardsModelState { + rewardsFeatureEnabled: boolean + showRewardsWidget: boolean + rewardsEnabled: boolean + externalWallet: ExternalWallet | null +} + +export function defaultState(): RewardsModelState { + return { + rewardsFeatureEnabled: false, + showRewardsWidget: false, + rewardsEnabled: false, + externalWallet: null + } +} + +export interface RewardsModel { + getState: () => RewardsModelState + addListener: (listener: (state: RewardsModelState) => void) => () => void + setShowRewardsWidget: (showRewardsWidget: boolean) => void +} + +export function defaultModel(): RewardsModel { + const state = defaultState() + return { + getState() { return state }, + addListener() { return () => {} }, + setShowRewardsWidget(showRewardsWidget) {} + } +} diff --git a/browser/resources/brave_new_tab/models/search_model.ts b/browser/resources/brave_new_tab/models/search_model.ts new file mode 100644 index 000000000000..f2cd38d350cf --- /dev/null +++ b/browser/resources/brave_new_tab/models/search_model.ts @@ -0,0 +1,90 @@ +/* Copyright (c) 2025 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 { + searchFeatureEnabled: boolean + showSearchBox: boolean + searchEngines: SearchEngineInfo[] + enabledSearchEngines: Set + lastUsedSearchEngine: string + searchSuggestionsEnabled: boolean + searchSuggestionsPromptDismissed: boolean + searchMatches: SearchResultMatch[] +} + +export function defaultState(): SearchState { + return { + searchFeatureEnabled: false, + 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/browser/resources/brave_new_tab/models/top_sites_model.ts b/browser/resources/brave_new_tab/models/top_sites_model.ts new file mode 100644 index 000000000000..135c9e3b1459 --- /dev/null +++ b/browser/resources/brave_new_tab/models/top_sites_model.ts @@ -0,0 +1,53 @@ +/* Copyright (c) 2025 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 interface TopSite { + title: string + url: string + favicon: string +} + +export type TopSitesListKind = 'custom' | 'most-visited' + +export interface TopSitesState { + showTopSites: boolean + listKind: TopSitesListKind + topSites: TopSite[] +} + +export function defaultState(): TopSitesState { + return { + showTopSites: true, + listKind: 'most-visited', + topSites: [] + } +} + +export interface TopSitesModel { + getState: () => TopSitesState + addListener: (listener: (state: TopSitesState) => void) => () => void + setShowTopSites: (showTopSites: boolean) => void + setListKind: (listKind: TopSitesListKind) => void + addTopSite: (url: string, title: string) => void + updateTopSite: (currentURL: string, newURL: string, title: string) => void + removeTopSite: (url: string) => void + undoRemoveTopSite: () => void + setTopSitePosition: (url: string, pos: number) => void +} + +export function defaultModel(): TopSitesModel { + const state = defaultState() + return { + getState() { return state }, + addListener() { return () => {} }, + setShowTopSites(showTopSites) {}, + setListKind(listKind) {}, + addTopSite(url, title) {}, + updateTopSite(currentURL, newURL, title) {}, + removeTopSite(url) {}, + undoRemoveTopSite() {}, + setTopSitePosition(url, pos) {} + } +} diff --git a/browser/resources/brave_new_tab/models/vpn_model.ts b/browser/resources/brave_new_tab/models/vpn_model.ts new file mode 100644 index 000000000000..058eb945d1df --- /dev/null +++ b/browser/resources/brave_new_tab/models/vpn_model.ts @@ -0,0 +1,56 @@ +/* Copyright (c) 2025 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 type ConnectionState = + 'connecting' | + 'connected' | + 'disconnecting' | + 'disconnected' + +interface ConnectionRegion { + country: string + name: string +} + +export interface VPNModelState { + vpnFeatureEnabled: boolean + showVpnWidget: boolean + purchased: boolean + connectionState: ConnectionState + connectionRegion: ConnectionRegion | null +} + +export function defaultState(): VPNModelState { + return { + vpnFeatureEnabled: false, + showVpnWidget: false, + purchased: false, + connectionState: 'disconnected', + connectionRegion: null + } +} + +export interface VPNModel { + getState: () => VPNModelState + addListener: (listener: (state: VPNModelState) => void) => () => void + setShowVpnWidget: (showVpnWidget: boolean) => void + startTrial: () => void + restorePurchase: () => void + toggleConnection: () => void + openVpnPanel: () => void +} + +export function defaultModel(): VPNModel { + const state = defaultState() + return { + getState() { return state }, + addListener() { return () => {} }, + setShowVpnWidget(showVpnWidget) {}, + startTrial() {}, + restorePurchase() {}, + toggleConnection() {}, + openVpnPanel() {} + } +} diff --git a/browser/resources/brave_new_tab/new_tab_page.html b/browser/resources/brave_new_tab/new_tab_page.html new file mode 100644 index 000000000000..072d0c84ea10 --- /dev/null +++ b/browser/resources/brave_new_tab/new_tab_page.html @@ -0,0 +1,22 @@ + + + + + + New Tab + + + + + + + + + +
+ + diff --git a/browser/resources/brave_new_tab/new_tab_page.tsx b/browser/resources/brave_new_tab/new_tab_page.tsx new file mode 100644 index 000000000000..4b2055eaf887 --- /dev/null +++ b/browser/resources/brave_new_tab/new_tab_page.tsx @@ -0,0 +1,56 @@ +/* Copyright (c) 2025 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 { createRoot } from 'react-dom/client' +import { setIconBasePath } from '@brave/leo/react/icon' + +import { LocaleContext } from './components/context/locale_context' +import { NewTabContext } from './components/context/new_tab_context' +import { createNewTabModel } from './webui/webui_new_tab_model' +import { SearchContext } from './components/context/search_context' +import { createSearchModel } from './webui/webui_search_model' +import { TopSitesContext } from './components/context/top_sites_context' +import { createTopSitesModel } from './webui/webui_top_sites_model' +import { RewardsContext } from './components/context/rewards_context' +import { createRewardsModel } from './webui/webui_rewards_model' +import { VPNContext } from './components/context/vpn_context' +import { createVPNModel } from './webui/webui_vpn_model' +import { createLocale } from './webui/webui_locale' +import { App } from './components/app' + +setIconBasePath('chrome://resources/brave-icons') + +const newTabModel = createNewTabModel() +const searchModel = createSearchModel() +const topSitesModel = createTopSitesModel() +const rewardsModel = createRewardsModel() +const vpnModel = createVPNModel() + +Object.assign(self, { + [Symbol.for('ntpInternals')]: { + newTabModel, + searchModel, + topSitesModel, + rewardsModel, + vpnModel + } +}) + +createRoot(document.getElementById('root')!).render( + + + + + + + + + + + + + +) diff --git a/browser/resources/brave_new_tab/stories/index.tsx b/browser/resources/brave_new_tab/stories/index.tsx new file mode 100644 index 000000000000..8fd06d5c5bab --- /dev/null +++ b/browser/resources/brave_new_tab/stories/index.tsx @@ -0,0 +1,44 @@ +/* Copyright (c) 2025 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 { LocaleContext } from '../components/context/locale_context' +import { NewTabContext } from '../components/context/new_tab_context' +import { SearchContext } from '../components/context/search_context' +import { TopSitesContext } from '../components/context/top_sites_context' +import { VPNContext } from '../components/context/vpn_context' +import { RewardsContext } from '../components/context/rewards_context' +import { createNewTabModel } from './sb_new_tab_model' +import { createSearchModel } from './sb_search_model' +import { createTopSitesModel } from './sb_top_sites_model' +import { createVPNModel } from './sb_vpn_model' +import { createRewardsModel } from './sb_rewards_model' +import { createLocale } from './sb_locale' +import { App } from '../components/app' + +export default { + title: 'New Tab/Next' +} + +export function NTPNext() { + return ( + + + + + + +
+ +
+
+
+
+
+
+
+ ) +} diff --git a/browser/resources/brave_new_tab/stories/sb_locale.ts b/browser/resources/brave_new_tab/stories/sb_locale.ts new file mode 100644 index 000000000000..1338738fa2fa --- /dev/null +++ b/browser/resources/brave_new_tab/stories/sb_locale.ts @@ -0,0 +1,107 @@ +/* Copyright (c) 2025 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 { StringKey } from '../lib/locale_strings' + +const localeStrings: { [K in StringKey]: string } = { + addTopSiteLabel: 'Add site', + addTopSiteTitle: 'Add site', + backgroundSettingsTitle: 'Background Image', + braveBackgroundLabel: 'Brave backgrounds', + cancelButtonLabel: 'Cancel', + clockFormatLabel: 'Format', + clockFormatOption12HourText: '12-hour clock', + clockFormatOption24HourText: '24-hour clock', + clockFormatOptionAutomaticText: 'Automatic ($1)', + clockSettingsTitle: 'Clock', + customBackgroundLabel: 'Use your own', + customBackgroundTitle: 'Use your own', + customizeSearchEnginesLink: 'Customize available engines', + editTopSiteLabel: 'Edit site', + editTopSiteTitle: 'Edit site', + enabledSearchEnginesLabel: 'Enabled search engines', + gradientBackgroundLabel: 'Gradients', + gradientBackgroundTitle: 'Gradients', + hideTopSitesLabel: 'Hide top sites', + photoCreditsText: 'Photo by $1', + randomizeBackgroundLabel: 'Refresh on every new tab', + removeTopSiteLabel: 'Remove', + rewardsConnectButtonLabel: 'Connect', + rewardsFeatureText1: 'Get paid for private ads you see in Brave', + rewardsFeatureText2: 'Get special benefits and discounts', + rewardsOnboardingButtonLabel: 'Start using Brave Rewards', + rewardsOnboardingLink: 'How does it work?', + rewardsWidgetTitle: 'REWARDS', + saveChangesButtonLabel: 'Save changes', + 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 Dashboard', + showBackgroundsLabel: 'Show background images', + showClockLabel: 'Show clock', + showRewardsWidgetLabel: 'Brave Rewards', + showSearchBoxLabel: 'Show search widget in new tabs', + showSponsoredImagesLabel: 'Show Sponsored Images', + showStatsLabel: 'Brave Stats', + showTalkWidgetLabel: 'Brave Talk', + showTopSitesLabel: 'Show top sites', + showVpnWidgetLabel: 'Brave VPN', + solidBackgroundLabel: 'Solid colors', + solidBackgroundTitle: 'Solid colors', + statsAdsBlockedText: 'Trackers & ads blocked', + statsBandwidthSavedText: 'Bandwidth saved', + statsSettingsTitle: 'Brave Stats', + statsTimeSavedText: 'Time saved', + statsTitle: 'STATS', + talkDescriptionText: 'Encrypted video calls with Brave Talk.', + talkDescriptionTitle: 'Privacy-First Calls', + talkStartCallLabel: 'Start call', + talkWidgetTitle: 'TALK', + topSiteRemovedText: 'Top site removed', + topSiteRemovedTitle: 'Removed', + topSitesCustomOptionText: 'Top sites are curated by you', + topSitesCustomOptionTitle: 'Favorites', + topSitesMostVisitedOptionText: 'Top sites are suggested based on websites you visit often.', + topSitesMostVisitedOptionTitle: 'Frequently Visited', + topSitesSettingsTitle: 'Top Sites', + topSitesShowCustomLabel: 'Show favorites', + topSitesShowMostVisitedLabel: 'Show frequently visited', + topSitesTitleLabel: 'Name', + topSitesURLLabel: 'URL', + undoButtonLabel: 'Undo', + uploadBackgroundLabel: 'Upload from device', + vpnChangeRegionLabel: 'Change', + vpnFeatureText1: 'Extra privacy & security online', + vpnFeatureText2: 'Hide your IP & change your location', + vpnFeatureText3: 'Protect every app on your device', + vpnRestorePurchaseLabel: 'Already purchased?', + vpnStartTrialLabel: 'Start free trial', + vpnOptimalText: 'Optimal', + vpnPoweredByText: 'Powered by', + vpnStatusConnected: 'Connected', + vpnStatusConnecting: 'Connecting', + vpnStatusDisconnected: 'Disconnected', + vpnStatusDisconnecting: 'Disconnecting', + vpnWidgetTitle: 'BRAVE VPN', + widgetLayoutLabel: 'Widget layout', + widgetSettingsTitle: 'Widgets' +} + +export function createLocale() { + const getString = + (key: string) => String((localeStrings as any)[key] || 'MISSING') + return { + getString, + async getPluralString (key: string, count: number) { + return getString(key).replace('#', String(count)) + } + } +} diff --git a/browser/resources/brave_new_tab/stories/sb_new_tab_model.ts b/browser/resources/brave_new_tab/stories/sb_new_tab_model.ts new file mode 100644 index 000000000000..0603b4457966 --- /dev/null +++ b/browser/resources/brave_new_tab/stories/sb_new_tab_model.ts @@ -0,0 +1,132 @@ +/* Copyright (c) 2025 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 { getCurrentBackground } from '../models/backgrounds' + +import { + NewTabModel, + SponsoredImageBackground, + defaultModel, + defaultState } from '../models/new_tab_model' + +function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +const sampleBackground = + 'https://brave.com/static-assets/images/brave-logo-sans-text.svg' + +const sampleSponsoredImage: SponsoredImageBackground = { + type: 'sponsored', + imageUrl: sampleBackground, + creativeInstanceId: '', + wallpaperId: '', + logo: { + alt: 'Be Brave!', + destinationUrl: 'https://brave.com', + imageUrl: sampleBackground + } +} + +export function createNewTabModel(): NewTabModel { + const store = createStore(defaultState()) + store.update({ + braveBackgrounds: [ + { + type: 'brave', + author: 'John Doe', + imageUrl: sampleBackground, + link: 'https://brave.com' + } + ], + sponsoredImageBackground: sampleSponsoredImage && null, + showClock: true, + showShieldsStats: true, + shieldsStats: { + adsBlocked: 3245, + bandwidthSavedBytes: 1024 * 1024 + }, + showTalkWidget: true, + widgetPosition: 'bottom' + }) + + store.update({ + currentBackground: getCurrentBackground(store.getState()) + }) + + return { + ...defaultModel(), + + getState: store.getState, + addListener: store.addListener, + + setBackgroundsEnabled(enabled) { + store.update({ backgroundsEnabled: enabled }) + store.update({ + currentBackground: getCurrentBackground(store.getState()) + }) + }, + + setSponsoredImagesEnabled(enabled) { + store.update({ sponsoredImagesEnabled: enabled }) + }, + + selectBackground(type, value) { + store.update({ + selectedBackgroundType: type, + selectedBackground: value + }) + store.update({ + currentBackground: getCurrentBackground(store.getState()) + }) + }, + + async showCustomBackgroundChooser() { + delay(200).then(() => { + store.update((state) => ({ + customBackgrounds: [...state.customBackgrounds, sampleBackground], + selectedBackground: sampleBackground, + selectedBackgroundType: 'custom' + })) + + store.update({ + currentBackground: getCurrentBackground(store.getState()) + }) + }) + + return true + }, + + async removeCustomBackground(background) { + store.update((state) => ({ + customBackgrounds: + state.customBackgrounds.filter((elem) => elem !== background) + })) + }, + + setClockFormat(format) { + store.update({ clockFormat: format }) + }, + + setShowClock(showClock) { + store.update({ showClock }) + }, + + setShowShieldsStats(showShieldsStats) { + store.update({ showShieldsStats }) + }, + + setShowTalkWidget(showTalkWidget) { + store.update({ showTalkWidget }) + }, + + setWidgetPosition(widgetPosition) { + store.update({ widgetPosition }) + } + } +} diff --git a/browser/resources/brave_new_tab/stories/sb_rewards_model.ts b/browser/resources/brave_new_tab/stories/sb_rewards_model.ts new file mode 100644 index 000000000000..02bd5355eaf0 --- /dev/null +++ b/browser/resources/brave_new_tab/stories/sb_rewards_model.ts @@ -0,0 +1,38 @@ +/* Copyright (c) 2025 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 { + RewardsModel, + RewardsModelState, + defaultModel, + defaultState } from '../models/rewards_model' + +export function createRewardsModel(): RewardsModel { + const store = createStore({ + ...defaultState(), + rewardsFeatureEnabled: true, + rewardsEnabled: true, + showRewardsWidget: true, + externalWallet: { + provider: 'uphold', + authenticated: true, + name: 'Joe', + url: 'https://brave.com' + } + }) + + return { + ...defaultModel(), + + getState: store.getState, + addListener: store.addListener, + + setShowRewardsWidget(showRewardsWidget) { + store.update({ showRewardsWidget }) + } + } +} diff --git a/browser/resources/brave_new_tab/stories/sb_search_model.ts b/browser/resources/brave_new_tab/stories/sb_search_model.ts new file mode 100644 index 000000000000..de6817f073fc --- /dev/null +++ b/browser/resources/brave_new_tab/stories/sb_search_model.ts @@ -0,0 +1,100 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/stories/sb_top_sites_model.ts b/browser/resources/brave_new_tab/stories/sb_top_sites_model.ts new file mode 100644 index 000000000000..605d7e522bcd --- /dev/null +++ b/browser/resources/brave_new_tab/stories/sb_top_sites_model.ts @@ -0,0 +1,114 @@ +/* Copyright (c) 2025 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 { + TopSitesModel, + TopSitesState, + TopSite, + defaultModel, + defaultState } from '../models/top_sites_model' + +export function createTopSitesModel(): TopSitesModel { + let lastRemovedSite: TopSite | null = null + + const store = createStore({ + ...defaultState(), + + showTopSites: true, + + listKind: 'custom', + + topSites: [...Array(12).keys()].flatMap((i) => { + return [ + { + title: 'Brave', + favicon: 'https://brave.com/favicon.ico', + url: `https://brave.com/#${i}` + }, + { + title: 'Wikipedia', + favicon: 'https://en.wikipedia.org/favicon.ico', + url: `https://en.wikipedia.org/#${i}` + } + ] + }) + }) + + return { + ...defaultModel(), + + getState: store.getState, + addListener: store.addListener, + + setShowTopSites(showTopSites) { + store.update({ showTopSites }) + }, + + setListKind(listKind) { + store.update({ listKind }) + }, + + addTopSite(url, title) { + store.update(({ topSites }) => { + return { + topSites: [ + ...topSites, + { url, title, favicon: 'https://brave.com/favicon.ico'} + ] + } + }) + }, + + updateTopSite(currentURL, newURL, title) { + store.update(({ topSites }) => { + return { + topSites: topSites.map((item) => { + if (item.url === currentURL) { + item.url = newURL + item.title = title + } + return item + }) + } + }) + }, + + removeTopSite(url) { + store.update(({ topSites }) => { + return { + topSites: topSites.filter((topSite) => { + if (topSite.url !== url) { + return true + } + lastRemovedSite = topSite + return false + }) + } + }) + }, + + undoRemoveTopSite() { + if (lastRemovedSite) { + store.update(({ topSites }) => { + return { topSites: [...topSites, lastRemovedSite!] } + }) + lastRemovedSite = null + } + }, + + setTopSitePosition(url, pos) { + store.update(({ topSites }) => { + const current = topSites.findIndex((item) => item.url === url) + if (current >= 0) { + const item = topSites.splice(current, 1)[0] + topSites.splice(pos, 0, item) + } + return { topSites: [...topSites] } + }) + } + } +} diff --git a/browser/resources/brave_new_tab/stories/sb_vpn_model.ts b/browser/resources/brave_new_tab/stories/sb_vpn_model.ts new file mode 100644 index 000000000000..0ca86df60055 --- /dev/null +++ b/browser/resources/brave_new_tab/stories/sb_vpn_model.ts @@ -0,0 +1,50 @@ +/* Copyright (c) 2025 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 { + VPNModel, + VPNModelState, + defaultModel, + defaultState } from '../models/vpn_model' + +export function createVPNModel(): VPNModel { + const store = createStore({ + ...defaultState(), + + vpnFeatureEnabled: true, + showVpnWidget: true, + purchased: false, + connectionState: 'connecting', + connectionRegion: { + country: 'Brazil', + name: 'Rio de Janeiro' + } + }) + + return { + ...defaultModel(), + + getState: store.getState, + addListener: store.addListener, + + setShowVpnWidget(showVpnWidget) { + store.update({ showVpnWidget }) + }, + + toggleConnection() { + store.update((state) => { + return { + connectionState: + state.connectionState === 'connected' || + state.connectionState === 'connecting' + ? 'disconnected' + : 'connected' + } + }) + } + } +} diff --git a/browser/resources/brave_new_tab/tsconfig.json b/browser/resources/brave_new_tab/tsconfig.json new file mode 100644 index 000000000000..3b6366185a85 --- /dev/null +++ b/browser/resources/brave_new_tab/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig", + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.d.ts" + ] +} diff --git a/browser/resources/brave_new_tab/webui/callback_listeners.ts b/browser/resources/brave_new_tab/webui/callback_listeners.ts new file mode 100644 index 000000000000..c8c5473960f9 --- /dev/null +++ b/browser/resources/brave_new_tab/webui/callback_listeners.ts @@ -0,0 +1,25 @@ +/* Copyright (c) 2025 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/. */ + +type Router = { + [P in keyof T]: { + addListener: (listener: any) => void + removeListener: (listener: any) => void + } +} + +export function addCallbackListeners( + router: Router, + listeners: Partial +) { + for (const [key, value] of Object.entries(listeners)) { + router[key as keyof T].addListener(value) + } + return () => { + for (const [key, value] of Object.entries(listeners)) { + router[key as keyof T].removeListener(value) + } + } +} diff --git a/browser/resources/brave_new_tab/webui/debounce_listener.ts b/browser/resources/brave_new_tab/webui/debounce_listener.ts new file mode 100644 index 000000000000..3958c6f983bd --- /dev/null +++ b/browser/resources/brave_new_tab/webui/debounce_listener.ts @@ -0,0 +1,10 @@ +/* Copyright (c) 2025 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 { debounce } from '$web-common/debounce' + +export function debounceListener(listener: (data: T) => void) { + return debounce(listener, 10) +} diff --git a/browser/resources/brave_new_tab/webui/new_tab_page_proxy.ts b/browser/resources/brave_new_tab/webui/new_tab_page_proxy.ts new file mode 100644 index 000000000000..e15759c5bf4f --- /dev/null +++ b/browser/resources/brave_new_tab/webui/new_tab_page_proxy.ts @@ -0,0 +1,35 @@ +/* Copyright (c) 2025 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/brave/browser/ui/webui/brave_new_tab/new_tab_page.mojom.m.js' + +import { addCallbackListeners } from './callback_listeners' + +let instance: NewTabPageProxy | null = null + +export class NewTabPageProxy { + callbackRouter: mojom.NewTabPageCallbackRouter + handler: mojom.NewTabPageHandlerRemote + + constructor(callbackRouter: mojom.NewTabPageCallbackRouter, + handler: mojom.NewTabPageHandlerRemote) { + this.callbackRouter = callbackRouter + this.handler = handler + } + + addListeners(listeners: Partial) { + return addCallbackListeners(this.callbackRouter, listeners) + } + + static getInstance(): NewTabPageProxy { + if (!instance) { + const callbackRouter = new mojom.NewTabPageCallbackRouter() + const handler = mojom.NewTabPageHandler.getRemote() + handler.setNewTabPage(callbackRouter.$.bindNewPipeAndPassRemote()) + instance = new NewTabPageProxy(callbackRouter, handler) + } + return instance + } +} diff --git a/browser/resources/brave_new_tab/webui/search_box_proxy.ts b/browser/resources/brave_new_tab/webui/search_box_proxy.ts new file mode 100644 index 000000000000..10dac940688c --- /dev/null +++ b/browser/resources/brave_new_tab/webui/search_box_proxy.ts @@ -0,0 +1,35 @@ +/* Copyright (c) 2025 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/browser/resources/brave_new_tab/webui/webui_locale.ts b/browser/resources/brave_new_tab/webui/webui_locale.ts new file mode 100644 index 000000000000..a6c8732c2078 --- /dev/null +++ b/browser/resources/brave_new_tab/webui/webui_locale.ts @@ -0,0 +1,18 @@ +/* Copyright (c) 2025 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 { loadTimeData } from 'chrome://resources/js/load_time_data.js' + +export function createLocale() { + return { + getString(key: string) { + return loadTimeData.getString(key) + }, + + async getPluralString(key: string, count: number) { + throw new Error('Not implemented') + } + } +} diff --git a/browser/resources/brave_new_tab/webui/webui_new_tab_model.ts b/browser/resources/brave_new_tab/webui/webui_new_tab_model.ts new file mode 100644 index 000000000000..41bb0caadb36 --- /dev/null +++ b/browser/resources/brave_new_tab/webui/webui_new_tab_model.ts @@ -0,0 +1,252 @@ +/* Copyright (c) 2025 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/brave/browser/ui/webui/brave_new_tab/new_tab_page.mojom.m.js' +import { loadTimeData } from 'chrome://resources/js/load_time_data.js' + +import { NewTabPageProxy } from './new_tab_page_proxy' +import { NewTabModel, WidgetPosition, BackgroundType, defaultState } from '../models/new_tab_model' +import { createStore } from '../lib/store' +import { getCurrentBackground } from '../models/backgrounds' +import { debounceListener } from './debounce_listener' + +export function backgroundTypeFromMojo(type: number): BackgroundType { + switch (type) { + case mojom.SelectedBackgroundType.kBrave: return 'brave' + case mojom.SelectedBackgroundType.kCustom: return 'custom' + case mojom.SelectedBackgroundType.kSolid: return 'solid' + case mojom.SelectedBackgroundType.kGradient: return 'gradient' + default: return 'none' + } +} + +export function backgroundTypeToMojo(type: BackgroundType) { + switch (type) { + case 'brave': return mojom.SelectedBackgroundType.kBrave + case 'custom': return mojom.SelectedBackgroundType.kCustom + case 'solid': return mojom.SelectedBackgroundType.kSolid + case 'gradient': return mojom.SelectedBackgroundType.kGradient + case 'none': return mojom.SelectedBackgroundType.kSolid + } +} + +function loadWidgetPosition(): WidgetPosition { + const value = localStorage.getItem('ntp-widget-position') + switch (value) { + case 'top': + case 'bottom': return value + default: return 'bottom' + } +} + +function storeWidgetPosition(position: WidgetPosition) { + localStorage.setItem('ntp-widget-position', position) +} + +export function createNewTabModel(): NewTabModel { + const newTabProxy = NewTabPageProxy.getInstance() + const { handler } = newTabProxy + const store = createStore(defaultState()) + const pcdnImageURLs = new Map() + + function updateCurrentBackground() { + store.update({ + currentBackground: getCurrentBackground(store.getState()) + }) + } + + async function updateBackgroundsEnabled() { + const { enabled } = await handler.getBackgroundsEnabled() + store.update({ backgroundsEnabled: enabled }) + } + + async function updateBackgroundsCustomizable() { + store.update({ + backgroundsCustomizable: + loadTimeData.getBoolean('customBackgroundFeatureEnabled') + }) + } + + async function updateSponsoredImagesEnabled() { + const { enabled } = await handler.getSponsoredImagesEnabled() + store.update({ sponsoredImagesEnabled: enabled }) + } + + async function updateBraveBackgrounds() { + const { backgrounds } = await handler.getBraveBackgrounds() + store.update({ + braveBackgrounds: backgrounds.map((item) => ({ type: 'brave', ...item })) + }) + } + + async function updateSelectedBackground() { + const { background } = await handler.getSelectedBackground() + if (background) { + store.update({ + selectedBackgroundType: backgroundTypeFromMojo(background.type), + selectedBackground: background.value + }) + } else { + store.update({ + selectedBackgroundType: 'none', + selectedBackground: '' + }) + } + } + + async function updateCustomBackgrounds() { + const { backgrounds } = await handler.getCustomBackgrounds() + store.update({ customBackgrounds: backgrounds }) + } + + async function updateSponsoredImageBackground() { + const { background } = await handler.getSponsoredImageBackground() + store.update({ + sponsoredImageBackground: + background ? { type: 'sponsored', ...background } : null + }) + } + + async function updateClockPrefs() { + const [ + { showClock }, + { clockFormat } + ] = await Promise.all([ + handler.getShowClock(), + handler.getClockFormat() + ]) + + store.update({ + showClock, + clockFormat: + clockFormat === 'h12' || clockFormat === 'h24' ? clockFormat : '' + }) + } + + async function updateShieldsStats() { + const [ + { showShieldsStats }, + { shieldsStats } + ] = await Promise.all([ + handler.getShowShieldsStats(), + handler.getShieldsStats() + ]) + + store.update({ showShieldsStats, shieldsStats }) + } + + async function updateTalkPrefs() { + const { showTalkWidget } = await handler.getShowTalkWidget() + store.update({ showTalkWidget }) + } + + function updateWidgetPosition() { + store.update({ widgetPosition: loadWidgetPosition() }) + } + + newTabProxy.addListeners({ + onBackgroundPrefsUpdated: debounceListener(async () => { + await Promise.all([ + updateCustomBackgrounds(), + updateSelectedBackground(), + ]) + updateCurrentBackground() + }), + onClockPrefsUpdated: debounceListener(updateClockPrefs), + onShieldsStatsPrefsUpdated: debounceListener(updateShieldsStats), + onTalkPrefsUpdated: debounceListener(updateTalkPrefs) + }) + + async function loadData() { + store.update({ widgetPosition: loadWidgetPosition() }) + + await Promise.all([ + updateBackgroundsEnabled(), + updateBackgroundsCustomizable(), + updateSponsoredImagesEnabled(), + updateBraveBackgrounds(), + updateCustomBackgrounds(), + updateSelectedBackground(), + updateSponsoredImageBackground(), + updateWidgetPosition(), + updateClockPrefs(), + updateShieldsStats(), + updateTalkPrefs() + ]) + + updateCurrentBackground() + } + + loadData() + + return { + getState: store.getState, + + 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) + }, + + setSponsoredImagesEnabled(enabled) { + store.update({ sponsoredImagesEnabled: enabled }) + handler.setSponsoredImagesEnabled(enabled) + }, + + selectBackground(type, value) { + store.update({ + selectedBackgroundType: type, + selectedBackground: value + }) + handler.selectBackground({ type: backgroundTypeToMojo(type), value }) + }, + + async showCustomBackgroundChooser() { + const { imagesSelected } = await handler.showCustomBackgroundChooser() + return imagesSelected + }, + + async removeCustomBackground(background) { + await handler.removeCustomBackground(background) + }, + + async setShowClock(showClock) { + await handler.setShowClock(showClock) + }, + + async setClockFormat(format) { + await handler.setClockFormat(format) + }, + + async setShowShieldsStats(showShieldsStats) { + await handler.setShowShieldsStats(showShieldsStats) + }, + + async setShowTalkWidget(showTalkWidget) { + await handler.setShowTalkWidget(showTalkWidget) + }, + + async setWidgetPosition(widgetPosition) { + storeWidgetPosition(widgetPosition) + updateWidgetPosition() + } + } +} diff --git a/browser/resources/brave_new_tab/webui/webui_rewards_model.ts b/browser/resources/brave_new_tab/webui/webui_rewards_model.ts new file mode 100644 index 000000000000..7bf93245a6d9 --- /dev/null +++ b/browser/resources/brave_new_tab/webui/webui_rewards_model.ts @@ -0,0 +1,72 @@ +/* Copyright (c) 2025 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 { loadTimeData } from 'chrome://resources/js/load_time_data.js' + +import { RewardsPageProxy } from '../../../../components/brave_rewards/resources/rewards_page/webui/rewards_page_proxy' +import { externalWalletFromExtensionData } from '../../../../components/brave_rewards/resources/shared/lib/external_wallet' +import { NewTabPageProxy } from './new_tab_page_proxy' +import { createStore } from '../lib/store' +import { debounceListener } from './debounce_listener' + +import { + RewardsModel, + defaultState, + defaultModel } from '../models/rewards_model' + +export function createRewardsModel(): RewardsModel { + if (!loadTimeData.getBoolean('rewardsFeatureEnabled')) { + return defaultModel() + } + + const newTabProxy = NewTabPageProxy.getInstance() + const newTabHandler = newTabProxy.handler + const rewardsProxy = RewardsPageProxy.getInstance() + const rewardsHandler = rewardsProxy.handler + const store = createStore(defaultState()) + + async function updatePrefs() { + const { showRewardsWidget } = await newTabHandler.getShowRewardsWidget() + store.update({ showRewardsWidget }) + } + + async function updateRewardsEnabled() { + const { paymentId } = await rewardsHandler.getRewardsPaymentId() + store.update({ rewardsEnabled: Boolean(paymentId) }) + } + + async function updateExternalWallet() { + const { externalWallet } = await rewardsHandler.getExternalWallet() + store.update({ + externalWallet: externalWalletFromExtensionData(externalWallet) + }) + } + + async function loadData() { + await Promise.all([ + updatePrefs(), + updateRewardsEnabled(), + updateExternalWallet() + ]) + } + + newTabProxy.addListeners({ + onRewardsPrefsUpdated: debounceListener(updatePrefs) + }) + + rewardsProxy.callbackRouter.onRewardsStateUpdated.addListener(loadData) + + loadData() + + return { + getState: store.getState, + + addListener: store.addListener, + + setShowRewardsWidget(showRewardsWidget) { + newTabHandler.setShowRewardsWidget(showRewardsWidget) + } + } +} diff --git a/browser/resources/brave_new_tab/webui/webui_search_model.ts b/browser/resources/brave_new_tab/webui/webui_search_model.ts new file mode 100644 index 000000000000..aa01c9cc40ff --- /dev/null +++ b/browser/resources/brave_new_tab/webui/webui_search_model.ts @@ -0,0 +1,212 @@ +/* Copyright (c) 2025 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 { loadTimeData } from 'chrome://resources/js/load_time_data.js' + +import { SearchBoxProxy } from './search_box_proxy' +import { NewTabPageProxy } from './new_tab_page_proxy' +import { createStore } from '../lib/store' + +import { + SearchModel, + SearchEngineInfo, + defaultSearchEngine, + defaultState, + defaultModel } 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 { + if (!loadTimeData.getBoolean('ntpSearchFeatureEnabled')) { + return defaultModel() + } + + 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) + } + } +} diff --git a/browser/resources/brave_new_tab/webui/webui_top_sites_model.ts b/browser/resources/brave_new_tab/webui/webui_top_sites_model.ts new file mode 100644 index 000000000000..d6fbc4e3d1eb --- /dev/null +++ b/browser/resources/brave_new_tab/webui/webui_top_sites_model.ts @@ -0,0 +1,118 @@ +/* Copyright (c) 2025 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/brave/browser/ui/webui/brave_new_tab/new_tab_page.mojom.m.js' + +import { NewTabPageProxy } from './new_tab_page_proxy' +import { TopSitesModel, TopSitesListKind, defaultState } from '../models/top_sites_model' +import { createStore } from '../lib/store' +import { debounceListener } from './debounce_listener' + +export function listKindFromMojo(type: number): TopSitesListKind { + switch (type) { + case mojom.TopSitesListKind.kCustom: return 'custom' + case mojom.TopSitesListKind.kMostVisited: return 'most-visited' + default: return 'most-visited' + } +} + +export function createTopSitesModel(): TopSitesModel { + const newTabProxy = NewTabPageProxy.getInstance() + const { handler } = newTabProxy + const store = createStore(defaultState()) + let lastExcludedMostVisitedSite = '' + + async function updatePrefs() { + const [ + { showTopSites }, + { listKind } + ] = await Promise.all([ + handler.getShowTopSites(), + handler.getTopSitesListKind() + ]) + + store.update({ + showTopSites, + listKind: listKindFromMojo(listKind) + }) + } + + async function updateTopSites() { + const { topSites } = await handler.getTopSites() + store.update({ topSites }) + } + + async function loadData() { + await Promise.all([ + updateTopSites(), + updatePrefs() + ]) + } + + newTabProxy.addListeners({ + onTopSitesPrefsUpdated: debounceListener(loadData), + onTopSitesListUpdated: () => loadData() + }) + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + updateTopSites() + } + }) + + loadData() + + return { + getState: store.getState, + + addListener: store.addListener, + + setShowTopSites(showTopSites) { + handler.setShowTopSites(showTopSites) + }, + + setListKind(listKind) { + handler.setTopSitesListKind(listKind === 'custom' + ? mojom.TopSitesListKind.kCustom + : mojom.TopSitesListKind.kMostVisited) + }, + + async addTopSite(url, title) { + await handler.addCustomTopSite(url, title) + }, + + async updateTopSite(currentURL, newURL, title) { + await handler.updateCustomTopSite(currentURL, newURL, title) + }, + + async removeTopSite(url) { + const { listKind } = store.getState() + if (listKind === 'most-visited') { + await handler.excludeMostVisitedTopSite(url) + lastExcludedMostVisitedSite = url + } else { + await handler.removeCustomTopSite(url) + } + }, + + async undoRemoveTopSite() { + const { listKind } = store.getState() + if (listKind === 'most-visited') { + if (lastExcludedMostVisitedSite) { + await handler.includeMostVisitedTopSite(lastExcludedMostVisitedSite) + lastExcludedMostVisitedSite = '' + } + } else { + await handler.undoCustomTopSiteAction() + } + }, + + async setTopSitePosition(url, pos) { + if (store.getState().listKind === 'custom') { + await handler.setCustomTopSitePosition(url, pos) + } + }, + } +} diff --git a/browser/resources/brave_new_tab/webui/webui_vpn_model.ts b/browser/resources/brave_new_tab/webui/webui_vpn_model.ts new file mode 100644 index 000000000000..9ec896fc6fa3 --- /dev/null +++ b/browser/resources/brave_new_tab/webui/webui_vpn_model.ts @@ -0,0 +1,148 @@ +/* Copyright (c) 2025 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 { loadTimeData } from 'chrome://resources/js/load_time_data.js' +import * as mojom from 'gen/brave/components/brave_vpn/common/mojom/brave_vpn.mojom.m' + +import { NewTabPageProxy } from './new_tab_page_proxy' +import { createStore } from '../lib/store' +import { debounceListener } from './debounce_listener' + +import { + VPNModel, + ConnectionState, + defaultState, + defaultModel } from '../models/vpn_model' + +function mapConnectionState(state: mojom.ConnectionState): ConnectionState { + switch (state) { + case mojom.ConnectionState.CONNECTED: + return 'connected' + case mojom.ConnectionState.CONNECTING: + return 'connecting' + case mojom.ConnectionState.DISCONNECTING: + return 'disconnecting' + default: + return 'disconnected' + } +} + +export function createVPNModel(): VPNModel { + if (!loadTimeData.getBoolean('vpnFeatureEnabled')) { + return defaultModel() + } + + const store = createStore(defaultState()) + const newTabProxy = NewTabPageProxy.getInstance() + const { handler } = newTabProxy + const vpnService = mojom.ServiceHandler.getRemote() + + async function updateConnectionInfo() { + const { state: purchasedState } = await vpnService.getPurchasedState() + const purchased = purchasedState.state === mojom.PurchasedState.PURCHASED + + store.update({ purchased }) + + if (!purchased) { + store.update({ + connectionState: 'disconnected', + connectionRegion: null + }) + return + } + + const [ + { state: connectionState }, + { currentRegion } + ] = await Promise.all([ + vpnService.getConnectionState(), + vpnService.getSelectedRegion() + ]) + + store.update({ + connectionState: mapConnectionState(connectionState), + connectionRegion: !currentRegion ? null : { + name: currentRegion.namePretty, + country: currentRegion.country + } + }) + } + + async function updatePrefs() { + const { showVpnWidget } = await handler.getShowVPNWidget() + store.update({ vpnFeatureEnabled: true, showVpnWidget }) + } + + async function loadData() { + await newTabProxy.handler.reloadVPNPurchasedState() + await Promise.all([ + updatePrefs(), + updateConnectionInfo() + ]) + } + + newTabProxy.addListeners({ + onVPNPrefsUpdated: debounceListener(updatePrefs) + }) + + const vpnServiceObserver = new mojom.ServiceObserverReceiver({ + onConnectionStateChanged(state) { + updateConnectionInfo() + }, + + onSelectedRegionChanged(region) { + updateConnectionInfo() + }, + + onPurchasedStateChanged(state, description) { + updateConnectionInfo() + } + }) + + vpnService.addObserver(vpnServiceObserver.$.bindNewPipeAndPassRemote()) + + loadData() + + return { + getState: store.getState, + + addListener: store.addListener, + + async setShowVpnWidget(showVpnWidget) { + await handler.setShowVPNWidget(showVpnWidget) + }, + + startTrial() { + handler.reportVPNWidgetUsage() + handler.openVPNAccountPage(mojom.ManageURLType.CHECKOUT) + }, + + restorePurchase() { + handler.reportVPNWidgetUsage() + handler.openVPNAccountPage(mojom.ManageURLType.RECOVER) + }, + + toggleConnection() { + const { connectionState } = store.getState() + switch (connectionState) { + case 'connected': + case 'connecting': + handler.reportVPNWidgetUsage() + vpnService.disconnect() + break + case 'disconnected': + case 'disconnecting': + handler.reportVPNWidgetUsage() + vpnService.connect() + break + } + }, + + openVpnPanel() { + handler.reportVPNWidgetUsage() + handler.openVPNPanel() + } + } +} diff --git a/browser/sources.gni b/browser/sources.gni index 61754f5dbfbc..289d46ab4464 100644 --- a/browser/sources.gni +++ b/browser/sources.gni @@ -294,6 +294,7 @@ if (!is_android) { brave_chrome_browser_deps += [ "//brave/browser/ui/ai_chat", "//brave/browser/ui/tabs:impl", + "//brave/browser/ui/webui/brave_new_tab", "//brave/browser/ui/webui/brave_news_internals", "//brave/browser/user_education:features", "//components/feed:feature_list", @@ -635,3 +636,10 @@ if (is_android) { "//brave/browser/android:tab_features", ] } + +# TODO(https://github.com/brave/brave-browser/issues/43310): new_tab_page_ui.cc +# includes some headers that are currently in the //chrome/browser target. +if (!is_android) { + brave_chrome_browser_allow_circular_includes_from += + [ "//brave/browser/ui/webui/brave_new_tab" ] +} diff --git a/browser/ui/config.gni b/browser/ui/config.gni index c436a93345c9..50c28f77bc60 100644 --- a/browser/ui/config.gni +++ b/browser/ui/config.gni @@ -10,6 +10,7 @@ brave_ui_allow_circular_includes_from = if (!is_android) { brave_ui_allow_circular_includes_from += [ "//brave/browser/ui/webui/brave_education", + "//brave/browser/ui/webui/brave_new_tab", "//brave/browser/ui/webui/brave_news_internals", ] } diff --git a/browser/ui/webui/brave_new_tab/BUILD.gn b/browser/ui/webui/brave_new_tab/BUILD.gn new file mode 100644 index 000000000000..c05a76dc1c67 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/BUILD.gn @@ -0,0 +1,75 @@ +# Copyright (c) 2025 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("//brave/components/brave_vpn/common/buildflags/buildflags.gni") +import("//brave/components/ntp_background_images/buildflags/buildflags.gni") +import("//mojo/public/tools/bindings/mojom.gni") + +assert(!is_android) +assert(enable_custom_background) + +mojom("mojom") { + sources = [ "new_tab_page.mojom" ] + deps = [ "//brave/components/brave_vpn/common/mojom" ] +} + +source_set("brave_new_tab") { + sources = [ + "background_adapter.cc", + "background_adapter.h", + "custom_image_chooser.cc", + "custom_image_chooser.h", + "new_tab_page_handler.cc", + "new_tab_page_handler.h", + "new_tab_page_ui.cc", + "new_tab_page_ui.h", + "top_sites_adapter.cc", + "top_sites_adapter.h", + "update_observer.cc", + "update_observer.h", + "vpn_adapter.cc", + "vpn_adapter.h", + ] + + deps = [ + ":mojom", + "//brave/browser:browser_process", + "//brave/browser/brave_ads", + "//brave/browser/brave_rewards", + "//brave/browser/brave_rewards:util", + "//brave/browser/ntp_background", + "//brave/browser/resources/brave_new_tab:generated_resources", + "//brave/components/brave_perf_predictor/common", + "//brave/components/brave_private_cdn", + "//brave/components/brave_rewards/core/mojom:webui", + "//brave/components/brave_search_conversion", + "//brave/components/brave_vpn/common/buildflags", + "//brave/components/l10n/common", + "//brave/components/ntp_background_images/browser", + "//brave/components/ntp_background_images/common", + "//brave/components/resources:static_resources", + "//brave/components/resources:strings", + "//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", + "//ui/shell_dialogs", + "//ui/webui", + ] + + if (enable_brave_vpn) { + deps += [ + "//brave/components/brave_vpn/browser", + "//brave/components/brave_vpn/common", + ] + } +} diff --git a/browser/ui/webui/brave_new_tab/DEPS b/browser/ui/webui/brave_new_tab/DEPS new file mode 100644 index 000000000000..3164da3fead0 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/DEPS @@ -0,0 +1,4 @@ +include_rules = [ + "+brave/components/brave_perf_predictor/common", + "+brave/components/brave_private_cdn", +] diff --git a/browser/ui/webui/brave_new_tab/background_adapter.cc b/browser/ui/webui/brave_new_tab/background_adapter.cc new file mode 100644 index 000000000000..c72804cd2a7a --- /dev/null +++ b/browser/ui/webui/brave_new_tab/background_adapter.cc @@ -0,0 +1,328 @@ +// Copyright (c) 2025 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/. + +#include "brave/browser/ui/webui/brave_new_tab/background_adapter.h" + +#include + +#include "base/barrier_callback.h" +#include "base/containers/contains.h" +#include "brave/browser/brave_browser_process.h" +#include "brave/browser/ntp_background/custom_background_file_manager.h" +#include "brave/browser/ntp_background/ntp_background_prefs.h" +#include "brave/components/ntp_background_images/browser/ntp_background_images_data.h" +#include "brave/components/ntp_background_images/browser/ntp_background_images_service.h" +#include "brave/components/ntp_background_images/browser/url_constants.h" +#include "brave/components/ntp_background_images/browser/view_counter_service.h" +#include "components/prefs/pref_service.h" + +namespace brave_new_tab { + +namespace { + +std::string GetCustomImageURL(const std::string& image_name) { + return CustomBackgroundFileManager::Converter(image_name).To().spec(); +} + +std::string CustomImageNameFromURL(const std::string& url) { + return CustomBackgroundFileManager::Converter(GURL(url)).To(); +} + +mojom::SponsoredImageBackgroundPtr ReadSponsoredImageData( + const base::Value::Dict& data) { + using ntp_background_images::kAltKey; + using ntp_background_images::kCampaignIdKey; + using ntp_background_images::kCreativeInstanceIDKey; + using ntp_background_images::kDestinationURLKey; + using ntp_background_images::kImageKey; + using ntp_background_images::kIsBackgroundKey; + using ntp_background_images::kLogoKey; + using ntp_background_images::kWallpaperIDKey; + using ntp_background_images::kWallpaperURLKey; + + auto is_background = data.FindBool(kIsBackgroundKey); + if (is_background.value_or(false)) { + return nullptr; + } + + auto background = mojom::SponsoredImageBackground::New(); + + if (auto* creative_instance_id = data.FindString(kCreativeInstanceIDKey)) { + background->creative_instance_id = *creative_instance_id; + } + + if (auto* wallpaper_id = data.FindString(kWallpaperIDKey)) { + background->wallpaper_id = *wallpaper_id; + } + + if (auto* campaign_id = data.FindString(kCampaignIdKey)) { + background->campaign_id = *campaign_id; + } + + if (auto* image_url = data.FindString(kWallpaperURLKey)) { + background->image_url = *image_url; + } + + if (auto* logo_dict = data.FindDict(kLogoKey)) { + auto logo = mojom::SponsoredImageLogo::New(); + if (auto* alt = logo_dict->FindString(kAltKey)) { + logo->alt = *alt; + } + if (auto* destination_url = logo_dict->FindString(kDestinationURLKey)) { + logo->destination_url = *destination_url; + } + if (auto* image_url = logo_dict->FindString(kImageKey)) { + logo->image_url = *image_url; + } + if (!logo->image_url.empty()) { + background->logo = std::move(logo); + } + } + + return background; +} + +NTPBackgroundPrefs GetBackgroundPrefs(const raw_ref& prefs) { + return NTPBackgroundPrefs(&prefs.get()); +} + +} // namespace + +BackgroundAdapter::BackgroundAdapter( + std::unique_ptr custom_file_manager, + PrefService& pref_service, + ntp_background_images::ViewCounterService* view_counter_service) + : custom_file_manager_(std::move(custom_file_manager)), + pref_service_(pref_service), + view_counter_service_(view_counter_service) { + CHECK(custom_file_manager_); +} + +BackgroundAdapter::~BackgroundAdapter() = default; + +std::vector +BackgroundAdapter::GetBraveBackgrounds() { + auto* service = g_brave_browser_process->ntp_background_images_service(); + if (!service) { + return {}; + } + + auto* image_data = service->GetBackgroundImagesData(); + if (!image_data || !image_data->IsValid()) { + return {}; + } + + std::vector backgrounds; + backgrounds.reserve(image_data->backgrounds.size()); + + for (auto& background : image_data->backgrounds) { + auto value = mojom::BraveBackground::New(); + value->image_url = image_data->url_prefix + + background.image_file.BaseName().AsUTF8Unsafe(); + value->author = background.author; + value->link = background.link; + backgrounds.push_back(std::move(value)); + } + + return backgrounds; +} + +std::vector BackgroundAdapter::GetCustomBackgrounds() { + auto backgrounds = GetBackgroundPrefs(pref_service_).GetCustomImageList(); + for (auto& background : backgrounds) { + background = GetCustomImageURL(background); + } + return backgrounds; +} + +mojom::SelectedBackgroundPtr BackgroundAdapter::GetSelectedBackground() { + auto background = mojom::SelectedBackground::New(); + + auto bg_prefs = GetBackgroundPrefs(pref_service_); + switch (bg_prefs.GetType()) { + case NTPBackgroundPrefs::Type::kBrave: + background->type = mojom::SelectedBackgroundType::kBrave; + break; + case NTPBackgroundPrefs::Type::kCustomImage: + background->type = mojom::SelectedBackgroundType::kCustom; + if (!bg_prefs.ShouldUseRandomValue()) { + background->value = GetCustomImageURL(bg_prefs.GetSelectedValue()); + } + break; + case NTPBackgroundPrefs::Type::kColor: + if (!bg_prefs.ShouldUseRandomValue()) { + background->value = bg_prefs.GetSelectedValue(); + background->type = base::Contains(background->value, "gradient") + ? mojom::SelectedBackgroundType::kGradient + : mojom::SelectedBackgroundType::kSolid; + } else if (bg_prefs.GetSelectedValue() == "gradient") { + background->type = mojom::SelectedBackgroundType::kGradient; + } else { + background->type = mojom::SelectedBackgroundType::kSolid; + } + break; + } + + return background; +} + +mojom::SponsoredImageBackgroundPtr +BackgroundAdapter::GetSponsoredImageBackground() { + if (!view_counter_service_) { + return nullptr; + } + + auto data = view_counter_service_->GetCurrentWallpaperForDisplay(); + if (!data) { + return nullptr; + } + + view_counter_service_->RegisterPageView(); + + auto sponsored_image = ReadSponsoredImageData(*data); + if (sponsored_image) { + view_counter_service_->BrandedWallpaperWillBeDisplayed( + sponsored_image->wallpaper_id, sponsored_image->creative_instance_id, + sponsored_image->campaign_id); + } + + return sponsored_image; +} + +void BackgroundAdapter::SelectBackground( + mojom::SelectedBackgroundPtr background) { + bool random = background->value.empty(); + std::string pref_value = background->value; + + auto bg_prefs = GetBackgroundPrefs(pref_service_); + + switch (background->type) { + case mojom::SelectedBackgroundType::kBrave: + bg_prefs.SetType(NTPBackgroundPrefs::Type::kBrave); + break; + case mojom::SelectedBackgroundType::kSolid: + bg_prefs.SetType(NTPBackgroundPrefs::Type::kColor); + if (random) { + pref_value = "solid"; + } + break; + case mojom::SelectedBackgroundType::kGradient: + bg_prefs.SetType(NTPBackgroundPrefs::Type::kColor); + if (random) { + pref_value = "gradient"; + } + break; + case mojom::SelectedBackgroundType::kCustom: + bg_prefs.SetType(NTPBackgroundPrefs::Type::kCustomImage); + if (!random) { + pref_value = CustomImageNameFromURL(background->value); + } + break; + } + + bg_prefs.SetSelectedValue(pref_value); + bg_prefs.SetShouldUseRandomValue(random); +} + +void BackgroundAdapter::SaveCustomBackgrounds(std::vector paths, + base::OnceClosure callback) { + // Create a repeating callback that will gather up the results of saving the + // custom images to the user's profile. + auto on_image_saved = base::BarrierCallback( + paths.size(), + base::BindOnce(&BackgroundAdapter::OnCustomBackgroundsSaved, + weak_factory_.GetWeakPtr(), std::move(callback))); + + // Since `CustomBackgroundFileManager` will run callbacks with a const ref + // to a base::FilePath, we need another step to copy the path. + auto copy_path = base::BindRepeating( + [](const base::FilePath& path) { return base::FilePath(path); }); + + for (auto& path : paths) { + custom_file_manager_->SaveImage(path, copy_path.Then(on_image_saved)); + } +} + +void BackgroundAdapter::RemoveCustomBackground( + const std::string& background_url, + base::OnceClosure callback) { + auto converter = CustomBackgroundFileManager::Converter( + GURL(background_url), custom_file_manager_.get()); + auto file_path = std::move(converter).To(); + custom_file_manager_->RemoveImage( + file_path, base::BindOnce(&BackgroundAdapter::OnCustomBackgroundRemoved, + weak_factory_.GetWeakPtr(), std::move(callback), + file_path)); +} + +void BackgroundAdapter::OnCustomBackgroundsSaved( + base::OnceClosure callback, + std::vector paths) { + auto bg_prefs = GetBackgroundPrefs(pref_service_); + + constexpr int kMaxCustomImageBackgrounds = 24; + auto can_add_image = [&bg_prefs] { + return bg_prefs.GetCustomImageList().size() < kMaxCustomImageBackgrounds; + }; + + std::string file_name; + + // For each successfully saved image, either add it to the custom image list + // or remove the file from the user's profile. + for (auto& path : paths) { + if (!path.empty()) { + if (can_add_image()) { + file_name = + CustomBackgroundFileManager::Converter(path).To(); + bg_prefs.AddCustomImageToList(file_name); + } else { + custom_file_manager_->RemoveImage(path, base::DoNothing()); + } + } + } + + // Select the last added image file as the current background. + if (!file_name.empty()) { + bg_prefs.SetType(NTPBackgroundPrefs::Type::kCustomImage); + bg_prefs.SetSelectedValue(file_name); + bg_prefs.SetShouldUseRandomValue(false); + } + + std::move(callback).Run(); +} + +void BackgroundAdapter::OnCustomBackgroundRemoved(base::OnceClosure callback, + base::FilePath path, + bool success) { + if (!success) { + std::move(callback).Run(); + return; + } + + auto file_name = + CustomBackgroundFileManager::Converter(path).To(); + + auto bg_prefs = GetBackgroundPrefs(pref_service_); + bg_prefs.RemoveCustomImageFromList(file_name); + + // If we are removing the currently selected background, either select the + // first remaining custom background, or, if there are none left, then select + // a default background. + if (bg_prefs.GetType() == NTPBackgroundPrefs::Type::kCustomImage && + bg_prefs.GetSelectedValue() == file_name) { + auto custom_images = bg_prefs.GetCustomImageList(); + if (custom_images.empty()) { + bg_prefs.SetType(NTPBackgroundPrefs::Type::kBrave); + bg_prefs.SetSelectedValue(""); + bg_prefs.SetShouldUseRandomValue(true); + } else { + bg_prefs.SetSelectedValue(custom_images.front()); + } + } + + std::move(callback).Run(); +} + +} // namespace brave_new_tab diff --git a/browser/ui/webui/brave_new_tab/background_adapter.h b/browser/ui/webui/brave_new_tab/background_adapter.h new file mode 100644 index 000000000000..b6beb32a51a1 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/background_adapter.h @@ -0,0 +1,71 @@ +// Copyright (c) 2025 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/. + +#ifndef BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_BACKGROUND_ADAPTER_H_ +#define BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_BACKGROUND_ADAPTER_H_ + +#include +#include +#include + +#include "base/memory/raw_ptr.h" +#include "base/memory/weak_ptr.h" +#include "brave/browser/ui/webui/brave_new_tab/new_tab_page.mojom.h" + +class CustomBackgroundFileManager; +class PrefService; + +namespace ntp_background_images { +class ViewCounterService; +} + +namespace brave_new_tab { + +// Provides access to background-related APIs for usage by the new tab page. +class BackgroundAdapter { + public: + BackgroundAdapter( + std::unique_ptr custom_file_manager, + PrefService& pref_service, + ntp_background_images::ViewCounterService* view_counter_service); + + ~BackgroundAdapter(); + + BackgroundAdapter(const BackgroundAdapter&) = delete; + BackgroundAdapter& operator=(const BackgroundAdapter&) = delete; + + std::vector GetBraveBackgrounds(); + + std::vector GetCustomBackgrounds(); + + mojom::SelectedBackgroundPtr GetSelectedBackground(); + + mojom::SponsoredImageBackgroundPtr GetSponsoredImageBackground(); + + void SelectBackground(mojom::SelectedBackgroundPtr background); + + void SaveCustomBackgrounds(std::vector paths, + base::OnceClosure callback); + + void RemoveCustomBackground(const std::string& background_url, + base::OnceClosure callback); + + private: + void OnCustomBackgroundsSaved(base::OnceClosure callback, + std::vector paths); + + void OnCustomBackgroundRemoved(base::OnceClosure callback, + base::FilePath path, + bool success); + + std::unique_ptr custom_file_manager_; + raw_ref pref_service_; + raw_ptr view_counter_service_; + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace brave_new_tab + +#endif // BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_BACKGROUND_ADAPTER_H_ diff --git a/browser/ui/webui/brave_new_tab/custom_image_chooser.cc b/browser/ui/webui/brave_new_tab/custom_image_chooser.cc new file mode 100644 index 000000000000..a6ddbe43e849 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/custom_image_chooser.cc @@ -0,0 +1,86 @@ +// Copyright (c) 2025 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/. + +#include "brave/browser/ui/webui/brave_new_tab/custom_image_chooser.h" + +#include + +#include "brave/components/l10n/common/localization_util.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/browser/ui/chrome_select_file_policy.h" +#include "chrome/grit/generated_resources.h" +#include "content/public/browser/web_contents.h" +#include "ui/shell_dialogs/selected_file_info.h" + +namespace brave_new_tab { + +CustomImageChooser::CustomImageChooser(content::WebContents& web_contents) + : web_contents_(web_contents), + profile_(*Profile::FromBrowserContext(web_contents.GetBrowserContext())) { +} + +CustomImageChooser::~CustomImageChooser() = default; + +void CustomImageChooser::ShowDialog(ShowDialogCallback callback) { + if (callback_) { + std::move(callback_).Run({}); + } + + callback_ = std::move(callback); + + if (dialog_) { + return; + } + + dialog_ = ui::SelectFileDialog::Create( + this, std::make_unique(&web_contents_.get())); + + ui::SelectFileDialog::FileTypeInfo file_types; + file_types.allowed_paths = ui::SelectFileDialog::FileTypeInfo::NATIVE_PATH; + file_types.extensions.push_back( + {{FILE_PATH_LITERAL("jpg"), FILE_PATH_LITERAL("jpeg"), + FILE_PATH_LITERAL("png"), FILE_PATH_LITERAL("gif")}}); + file_types.extension_description_overrides.push_back( + brave_l10n::GetLocalizedResourceUTF16String(IDS_UPLOAD_IMAGE_FORMAT)); + + dialog_->SelectFile(ui::SelectFileDialog::SELECT_OPEN_MULTI_FILE, + std::u16string(), profile_->last_selected_directory(), + &file_types, 0, base::FilePath::StringType(), + web_contents_->GetTopLevelNativeWindow(), nullptr); +} + +void CustomImageChooser::FileSelected(const ui::SelectedFileInfo& file, + int index) { + dialog_ = nullptr; + profile_->set_last_selected_directory(file.path().DirName()); + if (callback_) { + std::move(callback_).Run({file.path()}); + } +} + +void CustomImageChooser::MultiFilesSelected( + const std::vector& files) { + dialog_ = nullptr; + if (!files.empty()) { + profile_->set_last_selected_directory(files.back().path().DirName()); + } + std::vector paths; + paths.reserve(files.size()); + for (auto& file : files) { + paths.push_back(file.path()); + } + if (callback_) { + std::move(callback_).Run(std::move(paths)); + } +} + +void CustomImageChooser::FileSelectionCanceled() { + dialog_ = nullptr; + if (callback_) { + std::move(callback_).Run({}); + } +} + +} // namespace brave_new_tab diff --git a/browser/ui/webui/brave_new_tab/custom_image_chooser.h b/browser/ui/webui/brave_new_tab/custom_image_chooser.h new file mode 100644 index 000000000000..606a5c73b12d --- /dev/null +++ b/browser/ui/webui/brave_new_tab/custom_image_chooser.h @@ -0,0 +1,55 @@ +// Copyright (c) 2025 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/. + +#ifndef BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_CUSTOM_IMAGE_CHOOSER_H_ +#define BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_CUSTOM_IMAGE_CHOOSER_H_ + +#include +#include + +#include "base/files/file_path.h" +#include "base/functional/callback.h" +#include "base/memory/raw_ref.h" +#include "ui/shell_dialogs/select_file_dialog.h" + +class Profile; + +namespace content { +class WebContents; +} + +namespace brave_new_tab { + +// Displays a file chooser dialog for use on the New Tab Page, allowing the user +// to select background images from their device. +class CustomImageChooser : public ui::SelectFileDialog::Listener { + public: + explicit CustomImageChooser(content::WebContents& web_contents); + ~CustomImageChooser() override; + + CustomImageChooser(const CustomImageChooser&) = delete; + CustomImageChooser& operator=(const CustomImageChooser&) = delete; + + using ShowDialogCallback = + base::OnceCallback)>; + + void ShowDialog(ShowDialogCallback callback); + + // ui::SelectFileDialog::Listener: + void FileSelected(const ui::SelectedFileInfo& file, int index) override; + void MultiFilesSelected( + const std::vector& files) override; + void FileSelectionCanceled() override; + + private: + raw_ref web_contents_; + raw_ref profile_; + scoped_refptr dialog_; + ShowDialogCallback callback_; +}; + +} // namespace brave_new_tab + +#endif // BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_CUSTOM_IMAGE_CHOOSER_H_ diff --git a/browser/ui/webui/brave_new_tab/new_tab_page.mojom b/browser/ui/webui/brave_new_tab/new_tab_page.mojom new file mode 100644 index 000000000000..857761251c89 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/new_tab_page.mojom @@ -0,0 +1,249 @@ +// Copyright (c) 2025 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/. + +module brave_new_tab.mojom; + +import "brave/components/brave_vpn/common/mojom/brave_vpn.mojom"; + +struct BraveBackground { + string author; + string image_url; + string link; +}; + +enum SelectedBackgroundType { + kBrave, + kCustom, + kSolid, + kGradient, +}; + +struct SelectedBackground { + SelectedBackgroundType type; + string value; +}; + +struct SponsoredImageLogo { + string alt; + string destination_url; + string image_url; +}; + +struct SponsoredImageBackground { + string wallpaper_id; + string creative_instance_id; + string campaign_id; + string image_url; + 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; +}; + +struct TopSite { + string title; + string url; + string favicon; +}; + +enum TopSitesListKind { + kCustom, + kMostVisited +}; + +struct ShieldsStats { + double ads_blocked; + double bandwidth_saved_bytes; +}; + +// WebUI-side handler for notifications from the browser. +interface NewTabPage { + + // Called when a background-related profile preference has been updated. + OnBackgroundPrefsUpdated(); + + // Called when a clock-related profile preference has been updated. + OnClockPrefsUpdated(); + + // Called when a search-related profile preference has been updated. + OnSearchPrefsUpdated(); + + // Called when a top-sites-related profile preference has been updated. + OnTopSitesPrefsUpdated(); + + // Called when the list of most-visited sites has been updated. + OnTopSitesListUpdated(); + + // Called when preferences related to shields stats have been updated. + OnShieldsStatsPrefsUpdated(); + + // Called when preferences related to Brave Talk have been updated. + OnTalkPrefsUpdated(); + + // Called when a VPN profile preference has been updated. + OnVPNPrefsUpdated(); + + // Called when a Rewards NTP profile preference has been updated. + OnRewardsPrefsUpdated(); + +}; + +// Browser-side handler for requests from the WebUI page. +interface NewTabPageHandler { + + // Sets the NewTabPage remote interface that will receive notifications from + // 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); + SetBackgroundsEnabled(bool enabled) => (); + + // Gets or sets whether the user has enabled sponsored background images. + GetSponsoredImagesEnabled() => (bool enabled); + SetSponsoredImagesEnabled(bool enabled) => (); + + // Returns the current collection of Brave background images. + GetBraveBackgrounds() => (array backgrounds); + + // Returns the list of custom background images supplied by the user. + GetCustomBackgrounds() => (array backgrounds); + + // Returns the user-selected or default background. + GetSelectedBackground() => (SelectedBackground? background); + + // Returns sponsored image background info, if a sponsored image should be + // displayed to the user. + GetSponsoredImageBackground() => (SponsoredImageBackground? background); + + // Saves the provided background as the user's selected background. + SelectBackground(SelectedBackground background) => (); + + // Displays a file select dialog for selecting custom background images and + // returns a value indicating whether any image files were selected. If + // images were selected, they are added to the user's profile in the + // background. + ShowCustomBackgroundChooser() => (bool images_selected); + + // Removes the specified custom image background from the list of available + // 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) => (); + + // Gets or sets whether top sites are shown on the NTP + GetShowTopSites() => (bool show_top_sites); + SetShowTopSites(bool show_top_sites) => (); + + // Gets or sets the top sites list kind. + GetTopSitesListKind() => (TopSitesListKind list_kind); + SetTopSitesListKind(TopSitesListKind list_kind) => (); + + // Returns the current list of top sites for the user. + GetTopSites() => (array top_sites); + + // Adds a site to the list of custom top sites. + AddCustomTopSite(string url, string title) => (); + + // Updates the custom top site entry with the specified URL. + UpdateCustomTopSite(string url, string new_url, string title) => (); + + // Sets the position for a custom top site URL. Allows the user to reorder + // custom top sites. + SetCustomTopSitePosition(string url, int32 position) => (); + + // Removes a custom top sites entry. + RemoveCustomTopSite(string url) => (); + + // Reverses the most recent action on the custom top sites list. + UndoCustomTopSiteAction() => (); + + // Excludes a site from the "most visited" top sites list. + ExcludeMostVisitedTopSite(string url) => (); + + // Includes a site in the "most visited" top sites list, after it was excluded + // by a call to `ExcludeMostVisitedTopSite`. + IncludeMostVisitedTopSite(string url) => (); + + // Gets or sets whether the clock is displayed on the new tab page. + GetShowClock() => (bool show_clock); + SetShowClock(bool show_clock) => (); + + // Gets or sets the clock time format. + GetClockFormat() => (string clock_format); + SetClockFormat(string clock_format) => (); + + // Gets or sets whether the shields stats widget is displayed. + GetShowShieldsStats() => (bool show_shields_stats); + SetShowShieldsStats(bool show_shields_stats) => (); + + // Returns shields browsing stats for the current user. + GetShieldsStats() => (ShieldsStats shields_stats); + + // Gets or sets whether the Brave Talk widget is displayed. + GetShowTalkWidget() => (bool show_talk_widget); + SetShowTalkWidget(bool show_talk_widget) => (); + + // Gets or sets whether the Brave VPN widget is displayed. + GetShowVPNWidget() => (bool show_vpn_widget); + SetShowVPNWidget(bool show_vpn_widget) => (); + + // Reloads the user's VPN purchase state. + ReloadVPNPurchasedState() => (); + + // Opens the VPN panel. + OpenVPNPanel() => (); + + // Opens the specified VPN account page. + OpenVPNAccountPage(brave_vpn.mojom.ManageURLType url_type) => (); + + // Records VPN widget usage metrics. + ReportVPNWidgetUsage() => (); + + // Gets or sets whether the Brave Rewards widget is displayed. + GetShowRewardsWidget() => (bool show_rewards_widget); + SetShowRewardsWidget(bool show_rewards_widget) => (); + +}; 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 new file mode 100644 index 000000000000..e2e2bc9b874e --- /dev/null +++ b/browser/ui/webui/brave_new_tab/new_tab_page_handler.cc @@ -0,0 +1,552 @@ +// Copyright (c) 2025 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/. + +#include "brave/browser/ui/webui/brave_new_tab/new_tab_page_handler.h" + +#include + +#include "base/strings/string_util.h" +#include "base/strings/utf_string_conversions.h" +#include "brave/browser/ui/webui/brave_new_tab/background_adapter.h" +#include "brave/browser/ui/webui/brave_new_tab/custom_image_chooser.h" +#include "brave/browser/ui/webui/brave_new_tab/top_sites_adapter.h" +#include "brave/browser/ui/webui/brave_new_tab/vpn_adapter.h" +#include "brave/components/brave_perf_predictor/common/pref_names.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/constants/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_features.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 { + +NewTabPageHandler::NewTabPageHandler( + mojo::PendingReceiver receiver, + std::unique_ptr custom_image_chooser, + std::unique_ptr background_adapter, + std::unique_ptr top_sites_adapter, + std::unique_ptr vpn_adapter, + 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_adapter_(std::move(background_adapter)), + top_sites_adapter_(std::move(top_sites_adapter)), + vpn_adapter_(std::move(vpn_adapter)), + pcdn_helper_(std::move(pcdn_helper)), + tab_(tab), + pref_service_(pref_service), + template_url_service_(template_url_service) { + CHECK(custom_image_chooser_); + CHECK(background_adapter_); + CHECK(pcdn_helper_); + CHECK(vpn_adapter_); + + update_observer_.SetCallback(base::BindRepeating(&NewTabPageHandler::OnUpdate, + weak_factory_.GetWeakPtr())); + if (top_sites_adapter_) { + top_sites_adapter_->SetSitesUpdatedCallback(base::BindRepeating( + &NewTabPageHandler::OnTopSitesListUpdated, weak_factory_.GetWeakPtr())); + } +} + +NewTabPageHandler::~NewTabPageHandler() = default; + +void NewTabPageHandler::SetNewTabPage( + mojo::PendingRemote page) { + page_.reset(); + 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( + ntp_background_images::prefs::kNewTabPageShowBackgroundImage); + std::move(callback).Run(backgrounds_enabled); +} + +void NewTabPageHandler::SetBackgroundsEnabled( + bool enabled, + SetBackgroundsEnabledCallback callback) { + pref_service_->SetBoolean( + ntp_background_images::prefs::kNewTabPageShowBackgroundImage, enabled); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetSponsoredImagesEnabled( + GetSponsoredImagesEnabledCallback callback) { + bool sponsored_images_enabled = pref_service_->GetBoolean( + ntp_background_images::prefs:: + kNewTabPageShowSponsoredImagesBackgroundImage); + std::move(callback).Run(sponsored_images_enabled); +} + +void NewTabPageHandler::SetSponsoredImagesEnabled( + bool enabled, + SetSponsoredImagesEnabledCallback callback) { + pref_service_->SetBoolean(ntp_background_images::prefs:: + kNewTabPageShowSponsoredImagesBackgroundImage, + enabled); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetBraveBackgrounds( + GetBraveBackgroundsCallback callback) { + std::move(callback).Run(background_adapter_->GetBraveBackgrounds()); +} + +void NewTabPageHandler::GetCustomBackgrounds( + GetCustomBackgroundsCallback callback) { + std::move(callback).Run(background_adapter_->GetCustomBackgrounds()); +} + +void NewTabPageHandler::GetSelectedBackground( + GetSelectedBackgroundCallback callback) { + std::move(callback).Run(background_adapter_->GetSelectedBackground()); +} + +void NewTabPageHandler::GetSponsoredImageBackground( + GetSponsoredImageBackgroundCallback callback) { + std::move(callback).Run(background_adapter_->GetSponsoredImageBackground()); +} + +void NewTabPageHandler::SelectBackground( + mojom::SelectedBackgroundPtr background, + SelectBackgroundCallback callback) { + background_adapter_->SelectBackground(std::move(background)); + std::move(callback).Run(); +} + +void NewTabPageHandler::ShowCustomBackgroundChooser( + ShowCustomBackgroundChooserCallback callback) { + custom_image_chooser_->ShowDialog( + base::BindOnce(&NewTabPageHandler::OnCustomBackgroundsSelected, + weak_factory_.GetWeakPtr(), std::move(callback))); +} + +void NewTabPageHandler::RemoveCustomBackground( + const std::string& background_url, + RemoveCustomBackgroundCallback callback) { + background_adapter_->RemoveCustomBackground(background_url, + 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::GetShowTopSites(GetShowTopSitesCallback callback) { + if (top_sites_adapter_) { + std::move(callback).Run(top_sites_adapter_->GetTopSitesVisible()); + } else { + std::move(callback).Run(false); + } +} + +void NewTabPageHandler::SetShowTopSites(bool show_top_sites, + SetShowTopSitesCallback callback) { + if (top_sites_adapter_) { + top_sites_adapter_->SetTopSitesVisible(show_top_sites); + } + std::move(callback).Run(); +} + +void NewTabPageHandler::GetTopSitesListKind( + GetTopSitesListKindCallback callback) { + if (top_sites_adapter_) { + std::move(callback).Run(top_sites_adapter_->GetListKind()); + } else { + std::move(callback).Run(mojom::TopSitesListKind::kMostVisited); + } +} + +void NewTabPageHandler::SetTopSitesListKind( + mojom::TopSitesListKind list_kind, + SetTopSitesListKindCallback callback) { + if (top_sites_adapter_) { + top_sites_adapter_->SetListKind(list_kind); + } + std::move(callback).Run(); +} + +void NewTabPageHandler::GetTopSites(GetTopSitesCallback callback) { + if (!top_sites_adapter_) { + std::move(callback).Run({}); + return; + } + top_sites_adapter_->GetSites(std::move(callback)); +} + +void NewTabPageHandler::AddCustomTopSite(const std::string& url, + const std::string& title, + AddCustomTopSiteCallback callback) { + if (top_sites_adapter_) { + top_sites_adapter_->AddCustomSite(url, title); + } + std::move(callback).Run(); +} + +void NewTabPageHandler::UpdateCustomTopSite( + const std::string& url, + const std::string& new_url, + const std::string& title, + UpdateCustomTopSiteCallback callback) { + if (top_sites_adapter_) { + top_sites_adapter_->UpdateCustomSite(url, new_url, title); + } + std::move(callback).Run(); +} + +void NewTabPageHandler::RemoveCustomTopSite( + const std::string& url, + RemoveCustomTopSiteCallback callback) { + if (top_sites_adapter_) { + top_sites_adapter_->RemoveCustomSite(url); + } + std::move(callback).Run(); +} + +void NewTabPageHandler::UndoCustomTopSiteAction( + UndoCustomTopSiteActionCallback callback) { + if (top_sites_adapter_) { + top_sites_adapter_->UndoCustomSiteAction(); + } + std::move(callback).Run(); +} + +void NewTabPageHandler::ExcludeMostVisitedTopSite( + const std::string& url, + ExcludeMostVisitedTopSiteCallback callback) { + if (top_sites_adapter_) { + top_sites_adapter_->ExcludeMostVisitedSite(url); + } + std::move(callback).Run(); +} + +void NewTabPageHandler::IncludeMostVisitedTopSite( + const std::string& url, + IncludeMostVisitedTopSiteCallback callback) { + if (top_sites_adapter_) { + top_sites_adapter_->IncludeMostVisitedTopSite(url); + } + std::move(callback).Run(); +} + +void NewTabPageHandler::SetCustomTopSitePosition( + const std::string& url, + int32_t position, + SetCustomTopSitePositionCallback callback) { + if (top_sites_adapter_) { + top_sites_adapter_->SetCustomSitePosition(url, position); + } + std::move(callback).Run(); +} + +void NewTabPageHandler::OnCustomBackgroundsSelected( + ShowCustomBackgroundChooserCallback callback, + std::vector paths) { + // Before continuing, notify the caller of whether backgrounds were selected. + // This allows the front-end to display a loading indicator while the save + // operation is in progress. + std::move(callback).Run(!paths.empty()); + + if (!paths.empty()) { + background_adapter_->SaveCustomBackgrounds(std::move(paths), + base::DoNothing()); + } +} + +void NewTabPageHandler::GetShowClock(GetShowClockCallback callback) { + std::move(callback).Run(pref_service_->GetBoolean(kNewTabPageShowClock)); +} + +void NewTabPageHandler::SetShowClock(bool show_clock, + SetShowClockCallback callback) { + pref_service_->SetBoolean(kNewTabPageShowClock, show_clock); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetClockFormat(GetClockFormatCallback callback) { + std::move(callback).Run(pref_service_->GetString(kNewTabPageClockFormat)); +} + +void NewTabPageHandler::SetClockFormat(const std::string& clock_format, + SetClockFormatCallback callback) { + pref_service_->SetString(kNewTabPageClockFormat, clock_format); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetShowShieldsStats( + GetShowShieldsStatsCallback callback) { + std::move(callback).Run(pref_service_->GetBoolean(kNewTabPageShowStats)); +} + +void NewTabPageHandler::SetShowShieldsStats( + bool show_shields_stats, + SetShowShieldsStatsCallback callback) { + pref_service_->SetBoolean(kNewTabPageShowStats, show_shields_stats); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetShieldsStats(GetShieldsStatsCallback callback) { + auto stats = mojom::ShieldsStats::New(); + stats->ads_blocked = pref_service_->GetUint64(kAdsBlocked) + + pref_service_->GetUint64(kTrackersBlocked); + stats->bandwidth_saved_bytes = pref_service_->GetUint64( + brave_perf_predictor::prefs::kBandwidthSavedBytes); + std::move(callback).Run(std::move(stats)); +} + +void NewTabPageHandler::GetShowTalkWidget(GetShowTalkWidgetCallback callback) { + std::move(callback).Run(pref_service_->GetBoolean(kNewTabPageShowBraveTalk)); +} + +void NewTabPageHandler::SetShowTalkWidget(bool show_talk_widget, + SetShowTalkWidgetCallback callback) { + pref_service_->SetBoolean(kNewTabPageShowBraveTalk, show_talk_widget); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetShowVPNWidget(GetShowVPNWidgetCallback callback) { + if (auto pref_name = vpn_adapter_->GetWidgetPrefName()) { + std::move(callback).Run(pref_service_->GetBoolean(*pref_name)); + } else { + std::move(callback).Run(false); + } +} + +void NewTabPageHandler::SetShowVPNWidget(bool show_vpn_widget, + SetShowVPNWidgetCallback callback) { + if (auto pref_name = vpn_adapter_->GetWidgetPrefName()) { + pref_service_->SetBoolean(*pref_name, show_vpn_widget); + } else { + std::move(callback).Run(); + } +} + +void NewTabPageHandler::ReloadVPNPurchasedState( + ReloadVPNPurchasedStateCallback callback) { + vpn_adapter_->ReloadPurchasedState(); + std::move(callback).Run(); +} + +void NewTabPageHandler::OpenVPNPanel(OpenVPNPanelCallback callback) { + vpn_adapter_->OpenPanel(); + std::move(callback).Run(); +} + +void NewTabPageHandler::OpenVPNAccountPage( + brave_vpn::mojom::ManageURLType url_type, + OpenVPNAccountPageCallback callback) { + vpn_adapter_->OpenAccountPage(url_type); + std::move(callback).Run(); +} + +void NewTabPageHandler::ReportVPNWidgetUsage( + ReportVPNWidgetUsageCallback callback) { + vpn_adapter_->RecordWidgetUsage(); + std::move(callback).Run(); +} + +void NewTabPageHandler::GetShowRewardsWidget( + GetShowRewardsWidgetCallback callback) { + std::move(callback).Run(pref_service_->GetBoolean(kNewTabPageShowRewards)); +} + +void NewTabPageHandler::SetShowRewardsWidget( + bool show_rewards_widget, + SetShowRewardsWidgetCallback callback) { + pref_service_->SetBoolean(kNewTabPageShowRewards, show_rewards_widget); + std::move(callback).Run(); +} + +void NewTabPageHandler::OnUpdate(UpdateObserver::Source update_source) { + if (!page_.is_bound()) { + return; + } + switch (update_source) { + case UpdateObserver::Source::kBackgroundPrefs: + page_->OnBackgroundPrefsUpdated(); + break; + case UpdateObserver::Source::kClockPrefs: + page_->OnClockPrefsUpdated(); + break; + case UpdateObserver::Source::kSearchPrefs: + page_->OnSearchPrefsUpdated(); + break; + case UpdateObserver::Source::kTopSitesPrefs: + page_->OnTopSitesPrefsUpdated(); + break; + case UpdateObserver::Source::kShieldsStatsPrefs: + page_->OnShieldsStatsPrefsUpdated(); + break; + case UpdateObserver::Source::kTalkPrefs: + page_->OnTalkPrefsUpdated(); + break; + case UpdateObserver::Source::kVPNPrefs: + page_->OnVPNPrefsUpdated(); + break; + case UpdateObserver::Source::kRewardsPrefs: + page_->OnRewardsPrefsUpdated(); + break; + } +} + +void NewTabPageHandler::OnTopSitesListUpdated() { + if (!page_.is_bound()) { + return; + } + page_->OnTopSitesListUpdated(); +} + +} // namespace brave_new_tab 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 new file mode 100644 index 000000000000..6fcd004009c0 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/new_tab_page_handler.h @@ -0,0 +1,183 @@ +// Copyright (c) 2025 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/. + +#ifndef BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_NEW_TAB_PAGE_HANDLER_H_ +#define BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_NEW_TAB_PAGE_HANDLER_H_ + +#include +#include +#include + +#include "base/memory/raw_ptr.h" +#include "base/memory/raw_ref.h" +#include "base/memory/weak_ptr.h" +#include "brave/browser/ui/webui/brave_new_tab/new_tab_page.mojom.h" +#include "brave/browser/ui/webui/brave_new_tab/update_observer.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "mojo/public/cpp/bindings/pending_remote.h" +#include "mojo/public/cpp/bindings/receiver.h" +#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 { + +class BackgroundAdapter; +class CustomImageChooser; +class TopSitesAdapter; +class VPNAdapter; + +class NewTabPageHandler : public mojom::NewTabPageHandler { + public: + NewTabPageHandler( + mojo::PendingReceiver receiver, + std::unique_ptr custom_image_chooser, + std::unique_ptr background_adapter, + std::unique_ptr top_sites_adapter, + std::unique_ptr vpn_adapter, + 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; + void GetSponsoredImagesEnabled( + GetSponsoredImagesEnabledCallback callback) override; + void SetSponsoredImagesEnabled( + bool enabled, + SetSponsoredImagesEnabledCallback callback) override; + void GetBraveBackgrounds(GetBraveBackgroundsCallback callback) override; + void GetCustomBackgrounds(GetCustomBackgroundsCallback callback) override; + void GetSelectedBackground(GetSelectedBackgroundCallback callback) override; + void GetSponsoredImageBackground( + GetSponsoredImageBackgroundCallback callback) override; + void SelectBackground(mojom::SelectedBackgroundPtr background, + SelectBackgroundCallback callback) override; + void ShowCustomBackgroundChooser( + 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; + void GetShowTopSites(GetShowTopSitesCallback callback) override; + void SetShowTopSites(bool show_top_sites, + SetShowTopSitesCallback callback) override; + void GetTopSitesListKind(GetTopSitesListKindCallback callback) override; + void SetTopSitesListKind(mojom::TopSitesListKind list_kind, + SetTopSitesListKindCallback callback) override; + void GetTopSites(GetTopSitesCallback callback) override; + void AddCustomTopSite(const std::string& url, + const std::string& title, + AddCustomTopSiteCallback callback) override; + void UpdateCustomTopSite(const std::string& url, + const std::string& new_url, + const std::string& title, + UpdateCustomTopSiteCallback callback) override; + void SetCustomTopSitePosition( + const std::string& url, + int32_t position, + SetCustomTopSitePositionCallback callback) override; + void RemoveCustomTopSite(const std::string& url, + RemoveCustomTopSiteCallback callback) override; + void UndoCustomTopSiteAction( + UndoCustomTopSiteActionCallback callback) override; + void ExcludeMostVisitedTopSite( + const std::string& url, + ExcludeMostVisitedTopSiteCallback callback) override; + void IncludeMostVisitedTopSite( + const std::string& url, + IncludeMostVisitedTopSiteCallback callback) override; + void GetShowClock(GetShowClockCallback callback) override; + void SetShowClock(bool show_clock, SetShowClockCallback callback) override; + void GetClockFormat(GetClockFormatCallback callback) override; + void SetClockFormat(const std::string& clock_format, + SetClockFormatCallback callback) override; + void GetShowShieldsStats(GetShowShieldsStatsCallback callback) override; + void SetShowShieldsStats(bool show_shields_stats, + SetShowShieldsStatsCallback callback) override; + void GetShieldsStats(GetShieldsStatsCallback callback) override; + void GetShowTalkWidget(GetShowTalkWidgetCallback callback) override; + void SetShowTalkWidget(bool show_talk_widget, + SetShowTalkWidgetCallback callback) override; + void GetShowVPNWidget(GetShowVPNWidgetCallback callback) override; + void SetShowVPNWidget(bool show_vpn_widget, + SetShowVPNWidgetCallback callback) override; + void ReloadVPNPurchasedState( + ReloadVPNPurchasedStateCallback callback) override; + void OpenVPNPanel(OpenVPNPanelCallback callback) override; + void OpenVPNAccountPage(brave_vpn::mojom::ManageURLType url_type, + OpenVPNAccountPageCallback callback) override; + void ReportVPNWidgetUsage(ReportVPNWidgetUsageCallback callback) override; + void GetShowRewardsWidget(GetShowRewardsWidgetCallback callback) override; + void SetShowRewardsWidget(bool show_rewards_widget, + SetShowRewardsWidgetCallback callback) override; + + private: + void OnCustomBackgroundsSelected(ShowCustomBackgroundChooserCallback callback, + std::vector paths); + + void OnUpdate(UpdateObserver::Source update_source); + + void OnTopSitesListUpdated(); + + mojo::Receiver receiver_; + mojo::Remote page_; + UpdateObserver update_observer_; + std::unique_ptr custom_image_chooser_; + std::unique_ptr background_adapter_; + std::unique_ptr top_sites_adapter_; + std::unique_ptr vpn_adapter_; + std::unique_ptr pcdn_helper_; + raw_ref tab_; + raw_ref pref_service_; + raw_ref template_url_service_; + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace brave_new_tab + +#endif // BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_NEW_TAB_PAGE_HANDLER_H_ 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 new file mode 100644 index 000000000000..968ccf011687 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/new_tab_page_ui.cc @@ -0,0 +1,308 @@ +// Copyright (c) 2025 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/. + +#include "brave/browser/ui/webui/brave_new_tab/new_tab_page_ui.h" + +#include + +#include "brave/browser/brave_ads/ads_service_factory.h" +#include "brave/browser/brave_rewards/rewards_service_factory.h" +#include "brave/browser/brave_rewards/rewards_util.h" +#include "brave/browser/new_tab/new_tab_shows_options.h" +#include "brave/browser/ntp_background/brave_ntp_custom_background_service_factory.h" +#include "brave/browser/ntp_background/custom_background_file_manager.h" +#include "brave/browser/ntp_background/view_counter_service_factory.h" +#include "brave/browser/resources/brave_new_tab/grit/brave_new_tab_generated_map.h" +#include "brave/browser/ui/brave_ui_features.h" +#include "brave/browser/ui/webui/brave_new_tab/background_adapter.h" +#include "brave/browser/ui/webui/brave_new_tab/custom_image_chooser.h" +#include "brave/browser/ui/webui/brave_new_tab/new_tab_page_handler.h" +#include "brave/browser/ui/webui/brave_new_tab/top_sites_adapter.h" +#include "brave/browser/ui/webui/brave_new_tab/vpn_adapter.h" +#include "brave/browser/ui/webui/brave_rewards/rewards_page_handler.h" +#include "brave/browser/ui/webui/brave_webui_source.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/ntp_tiles/chrome_most_visited_sites_factory.h" +#include "chrome/browser/profiles/profile.h" +#include "chrome/browser/search_engines/template_url_service_factory.h" +#include "chrome/browser/themes/theme_syncable_service.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 "components/favicon_base/favicon_url_parser.h" +#include "components/grit/brave_components_resources.h" +#include "components/grit/brave_components_strings.h" +#include "components/ntp_tiles/most_visited_sites.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/webui/webui_util.h" + +#if BUILDFLAG(ENABLE_BRAVE_VPN) +#include "brave/browser/brave_vpn/brave_vpn_service_factory.h" +#include "brave/components/brave_vpn/browser/brave_vpn_service.h" +#include "brave/components/brave_vpn/common/brave_vpn_utils.h" +#endif + +namespace brave_new_tab { + +namespace { + +static constexpr webui::LocalizedString kStrings[] = { + {"addTopSiteLabel", IDS_NEW_TAB_ADD_TOP_SITE_LABEL}, + {"addTopSiteTitle", IDS_NEW_TAB_ADD_TOP_SITE_TITLE}, + {"backgroundSettingsTitle", IDS_NEW_TAB_BACKGROUND_SETTINGS_TITLE}, + {"braveBackgroundLabel", IDS_NEW_TAB_BRAVE_BACKGROUND_LABEL}, + {"cancelButtonLabel", IDS_NEW_TAB_CANCEL_BUTTON_LABEL}, + {"clockFormatLabel", IDS_NEW_TAB_CLOCK_FORMAT_LABEL}, + {"clockFormatOption12HourText", IDS_NEW_TAB_CLOCK_FORMAT_OPTION12HOUR_TEXT}, + {"clockFormatOption24HourText", IDS_NEW_TAB_CLOCK_FORMAT_OPTION24HOUR_TEXT}, + {"clockFormatOptionAutomaticText", + IDS_NEW_TAB_CLOCK_FORMAT_OPTION_AUTOMATIC_TEXT}, + {"clockSettingsTitle", IDS_NEW_TAB_CLOCK_SETTINGS_TITLE}, + {"customBackgroundLabel", IDS_NEW_TAB_CUSTOM_BACKGROUND_LABEL}, + {"customBackgroundTitle", IDS_NEW_TAB_CUSTOM_BACKGROUND_LABEL}, + {"customizeSearchEnginesLink", IDS_NEW_TAB_CUSTOMIZE_SEARCH_ENGINES_LINK}, + {"editTopSiteLabel", IDS_NEW_TAB_EDIT_TOP_SITE_LABEL}, + {"editTopSiteTitle", IDS_NEW_TAB_EDIT_TOP_SITE_TITLE}, + {"enabledSearchEnginesLabel", IDS_NEW_TAB_ENABLED_SEARCH_ENGINES_LABEL}, + {"gradientBackgroundLabel", IDS_NEW_TAB_GRADIENT_BACKGROUND_LABEL}, + {"gradientBackgroundTitle", IDS_NEW_TAB_GRADIENT_BACKGROUND_LABEL}, + {"hideTopSitesLabel", IDS_NEW_TAB_HIDE_TOP_SITES_LABEL}, + {"photoCreditsText", IDS_NEW_TAB_PHOTO_CREDITS_TEXT}, + {"randomizeBackgroundLabel", IDS_NEW_TAB_RANDOMIZE_BACKGROUND_LABEL}, + {"removeTopSiteLabel", IDS_NEW_TAB_REMOVE_TOP_SITE_LABEL}, + {"rewardsConnectButtonLabel", IDS_NEW_TAB_REWARDS_CONNECT_BUTTON_LABEL}, + {"rewardsFeatureText1", IDS_REWARDS_ONBOARDING_TEXT_ITEM_1}, + {"rewardsFeatureText2", IDS_REWARDS_ONBOARDING_TEXT_ITEM_2}, + {"rewardsOnboardingButtonLabel", IDS_REWARDS_ONBOARDING_BUTTON_LABEL}, + {"rewardsOnboardingLink", IDS_NEW_TAB_REWARDS_ONBOARDING_LINK}, + {"rewardsWidgetTitle", IDS_NEW_TAB_REWARDS_WIDGET_TITLE}, + {"saveChangesButtonLabel", IDS_NEW_TAB_SAVE_CHANGES_BUTTON_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}, + {"showClockLabel", IDS_NEW_TAB_SHOW_CLOCK_LABEL}, + {"showRewardsWidgetLabel", IDS_NEW_TAB_SHOW_REWARDS_WIDGET_LABEL}, + {"showSearchBoxLabel", IDS_NEW_TAB_SHOW_SEARCH_BOX_LABEL}, + {"showSponsoredImagesLabel", IDS_NEW_TAB_SHOW_SPONSORED_IMAGES_LABEL}, + {"showStatsLabel", IDS_NEW_TAB_SHOW_STATS_LABEL}, + {"showTalkWidgetLabel", IDS_NEW_TAB_SHOW_TALK_WIDGET_LABEL}, + {"showTopSitesLabel", IDS_NEW_TAB_SHOW_TOP_SITES_LABEL}, + {"showVpnWidgetLabel", IDS_NEW_TAB_SHOW_VPN_WIDGET_LABEL}, + {"solidBackgroundLabel", IDS_NEW_TAB_SOLID_BACKGROUND_LABEL}, + {"solidBackgroundTitle", IDS_NEW_TAB_SOLID_BACKGROUND_LABEL}, + {"statsAdsBlockedText", IDS_NEW_TAB_STATS_ADS_BLOCKED_TEXT}, + {"statsBandwidthSavedText", IDS_NEW_TAB_STATS_BANDWIDTH_SAVED_TEXT}, + {"statsSettingsTitle", IDS_NEW_TAB_STATS_SETTINGS_TITLE}, + {"statsTimeSavedText", IDS_NEW_TAB_STATS_TIME_SAVED_TEXT}, + {"statsTitle", IDS_NEW_TAB_STATS_TITLE}, + {"talkDescriptionText", IDS_NEW_TAB_TALK_DESCRIPTION_TEXT}, + {"talkDescriptionTitle", IDS_NEW_TAB_TALK_DESCRIPTION_TITLE}, + {"talkStartCallLabel", IDS_NEW_TAB_TALK_START_CALL_LABEL}, + {"talkWidgetTitle", IDS_NEW_TAB_TALK_WIDGET_TITLE}, + {"topSiteRemovedText", IDS_NEW_TAB_TOP_SITE_REMOVED_TEXT}, + {"topSiteRemovedTitle", IDS_NEW_TAB_TOP_SITE_REMOVED_TITLE}, + {"topSitesCustomOptionText", IDS_NEW_TAB_TOP_SITES_CUSTOM_OPTION_TEXT}, + {"topSitesCustomOptionTitle", IDS_NEW_TAB_TOP_SITES_CUSTOM_OPTION_TITLE}, + {"topSitesMostVisitedOptionText", + IDS_NEW_TAB_TOP_SITES_MOST_VISITED_OPTION_TEXT}, + {"topSitesMostVisitedOptionTitle", + IDS_NEW_TAB_TOP_SITES_MOST_VISITED_OPTION_TITLE}, + {"topSitesSettingsTitle", IDS_NEW_TAB_TOP_SITES_SETTINGS_TITLE}, + {"topSitesShowCustomLabel", IDS_NEW_TAB_TOP_SITES_SHOW_CUSTOM_LABEL}, + {"topSitesShowMostVisitedLabel", + IDS_NEW_TAB_TOP_SITES_SHOW_MOST_VISITED_LABEL}, + {"topSitesTitleLabel", IDS_NEW_TAB_TOP_SITES_TITLE_LABEL}, + {"topSitesURLLabel", IDS_NEW_TAB_TOP_SITES_URL_LABEL}, + {"undoButtonLabel", IDS_NEW_TAB_UNDO_BUTTON_LABEL}, + {"uploadBackgroundLabel", IDS_NEW_TAB_UPLOAD_BACKGROUND_LABEL}, + {"vpnChangeRegionLabel", IDS_NEW_TAB_VPN_CHANGE_REGION_LABEL}, + {"vpnFeatureText1", IDS_NEW_TAB_VPN_FEATURE_TEXT1}, + {"vpnFeatureText2", IDS_NEW_TAB_VPN_FEATURE_TEXT2}, + {"vpnFeatureText3", IDS_NEW_TAB_VPN_FEATURE_TEXT3}, + {"vpnRestorePurchaseLabel", IDS_NEW_TAB_VPN_RESTORE_PURCHASE_LABEL}, + {"vpnStartTrialLabel", IDS_NEW_TAB_VPN_START_TRIAL_LABEL}, + {"vpnOptimalText", IDS_NEW_TAB_VPN_OPTIMAL_TEXT}, + {"vpnPoweredByText", IDS_NEW_TAB_VPN_POWERED_BY_TEXT}, + {"vpnStatusConnected", IDS_NEW_TAB_VPN_STATUS_CONNECTED}, + {"vpnStatusConnecting", IDS_NEW_TAB_VPN_STATUS_CONNECTING}, + {"vpnStatusDisconnected", IDS_NEW_TAB_VPN_STATUS_DISCONNECTED}, + {"vpnStatusDisconnecting", IDS_NEW_TAB_VPN_STATUS_DISCONNECTING}, + {"vpnWidgetTitle", IDS_NEW_TAB_VPN_WIDGET_TITLE}, + {"widgetLayoutLabel", IDS_NEW_TAB_WIDGET_LAYOUT_LABEL}, + {"widgetSettingsTitle", IDS_NEW_TAB_WIDGET_SETTINGS_TITLE}}; + +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) { + auto* custom_background_service = + BraveNTPCustomBackgroundServiceFactory::GetForContext(profile); + if (!custom_background_service) { + return; + } + auto source = std::make_unique( + custom_background_service); + content::URLDataSource::Add(profile, std::move(source)); +} + +} // namespace + +NewTabPageUI::NewTabPageUI(content::WebUI* web_ui) + : ui::MojoWebUIController(web_ui) { + auto* profile = Profile::FromWebUI(web_ui); + + auto* source = content::WebUIDataSource::CreateAndAdd( + profile, chrome::kChromeUINewTabHost); + + if (brave::ShouldNewTabShowBlankpage(profile)) { + source->SetDefaultResource(IDR_BRAVE_BLANK_NEW_TAB_HTML); + } else { + webui::SetupWebUIDataSource(source, kBraveNewTabGenerated, + IDR_BRAVE_NEW_TAB_PAGE_HTML); + } + + source->OverrideContentSecurityPolicy( + network::mojom::CSPDirectiveName::ImgSrc, + "img-src chrome://resources chrome://theme chrome://background-wallpaper " + "chrome://custom-wallpaper chrome://branded-wallpaper chrome://favicon2 " + "blob: data: 'self';"); + + 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)); + + source->AddLocalizedStrings(kStrings); + + source->AddBoolean( + "customBackgroundFeatureEnabled", + !profile->GetPrefs()->IsManagedPreference(GetThemePrefNameInMigration( + ThemePrefInMigration::kNtpCustomBackgroundDict))); + + source->AddBoolean( + "ntpSearchFeatureEnabled", + base::FeatureList::IsEnabled(features::kBraveNtpSearchWidget)); + +#if BUILDFLAG(ENABLE_BRAVE_VPN) + bool vpn_feature_enabled = brave_vpn::IsBraveVPNEnabled(profile->GetPrefs()); +#else + bool vpn_feature_enabled = false; +#endif + + source->AddBoolean("vpnFeatureEnabled", vpn_feature_enabled); + + source->AddBoolean("rewardsFeatureEnabled", + brave_rewards::IsSupportedForProfile(profile)); +} + +NewTabPageUI::~NewTabPageUI() = default; + +void NewTabPageUI::BindInterface( + mojo::PendingReceiver receiver) { + auto* web_contents = web_ui()->GetWebContents(); + auto* profile = Profile::FromWebUI(web_ui()); + auto* prefs = profile->GetPrefs(); + + auto image_chooser = + std::make_unique(*web_ui()->GetWebContents()); + + auto background_adapter = std::make_unique( + std::make_unique(profile), *prefs, + ntp_background_images::ViewCounterServiceFactory::GetForProfile(profile)); + + auto top_sites_adapter = std::make_unique( + ChromeMostVisitedSitesFactory::NewForProfile(profile), *prefs); + + auto pcdn_helper = + std::make_unique( + kPcdnImageLoaderTrafficAnnotation, profile->GetURLLoaderFactory()); + + auto* tab = tabs::TabInterface::GetFromContents(web_contents); + +#if BUILDFLAG(ENABLE_BRAVE_VPN) + auto vpn_adapter = std::make_unique( + *tab, brave_vpn::BraveVpnServiceFactory::GetForProfile(profile)); +#else + auto vpn_adapter = std::make_unique(); +#endif + + page_handler_ = std::make_unique( + std::move(receiver), std::move(image_chooser), + std::move(background_adapter), std::move(top_sites_adapter), + std::move(vpn_adapter), std::move(pcdn_helper), *tab, *prefs, + *TemplateURLServiceFactory::GetForProfile(profile)); +} + +void NewTabPageUI::BindInterface( + mojo::PendingReceiver receiver) { + realbox_handler_ = std::make_unique( + std::move(receiver), Profile::FromWebUI(web_ui()), + web_ui()->GetWebContents(), /*metrics_reporter=*/nullptr, + /*lens_searchbox_client=*/nullptr, /*omnibox_controller=*/nullptr); +} + +void NewTabPageUI::BindInterface( + mojo::PendingReceiver receiver) { + auto* profile = Profile::FromWebUI(web_ui()); + rewards_page_handler_ = std::make_unique( + std::move(receiver), nullptr, + brave_rewards::RewardsServiceFactory::GetForProfile(profile), + brave_ads::AdsServiceFactory::GetForProfile(profile), nullptr, + profile->GetPrefs()); +} + +#if BUILDFLAG(ENABLE_BRAVE_VPN) +void NewTabPageUI::BindInterface( + mojo::PendingReceiver receiver) { + auto* vpn_service = brave_vpn::BraveVpnServiceFactory::GetForProfile( + Profile::FromWebUI(web_ui())); + if (vpn_service) { + vpn_service->BindInterface(std::move(receiver)); + } +} +#endif + +WEB_UI_CONTROLLER_TYPE_IMPL(NewTabPageUI) + +} // namespace brave_new_tab 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 new file mode 100644 index 000000000000..803824191476 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/new_tab_page_ui.h @@ -0,0 +1,64 @@ +// Copyright (c) 2025 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/. + +#ifndef BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_NEW_TAB_PAGE_UI_H_ +#define BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_NEW_TAB_PAGE_UI_H_ + +#include + +#include "brave/browser/ui/webui/brave_new_tab/new_tab_page.mojom.h" +#include "brave/components/brave_rewards/core/mojom/rewards_page.mojom.h" +#include "brave/components/brave_vpn/common/buildflags/buildflags.h" +#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" + +#if BUILDFLAG(ENABLE_BRAVE_VPN) +#include "brave/components/brave_vpn/common/mojom/brave_vpn.mojom.h" +#endif + +namespace brave_rewards { +class RewardsPageHandler; +} + +namespace content { +class WebUI; +} + +class RealboxHandler; + +namespace brave_new_tab { + +// The Web UI controller for the Brave new tab page. +class NewTabPageUI : public ui::MojoWebUIController { + public: + explicit NewTabPageUI(content::WebUI* web_ui); + ~NewTabPageUI() override; + + void BindInterface(mojo::PendingReceiver receiver); + + void BindInterface( + mojo::PendingReceiver receiver); + + void BindInterface( + mojo::PendingReceiver receiver); + +#if BUILDFLAG(ENABLE_BRAVE_VPN) + void BindInterface( + mojo::PendingReceiver receiver); +#endif + + private: + std::unique_ptr page_handler_; + std::unique_ptr realbox_handler_; + std::unique_ptr rewards_page_handler_; + + WEB_UI_CONTROLLER_TYPE_DECL(); +}; + +} // namespace brave_new_tab + +#endif // BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_NEW_TAB_PAGE_UI_H_ diff --git a/browser/ui/webui/brave_new_tab/top_sites_adapter.cc b/browser/ui/webui/brave_new_tab/top_sites_adapter.cc new file mode 100644 index 000000000000..1fb22e037227 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/top_sites_adapter.cc @@ -0,0 +1,142 @@ +// Copyright (c) 2025 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/. + +#include "brave/browser/ui/webui/brave_new_tab/top_sites_adapter.h" + +#include + +#include "base/strings/utf_string_conversions.h" +#include "chrome/browser/ui/webui/new_tab_page/ntp_pref_names.h" +#include "components/ntp_tiles/constants.h" +#include "components/prefs/pref_service.h" + +namespace brave_new_tab { + +namespace { + +std::vector TopSitesFromSections( + const std::map& + sections) { + std::vector top_sites; + for (auto& tile : sections.at(ntp_tiles::SectionType::PERSONALIZED)) { + auto site = mojom::TopSite::New(); + site->title = base::UTF16ToUTF8(tile.title); + site->url = tile.url.spec(); + site->favicon = tile.favicon_url.spec(); + if (site->title.empty()) { + site->title = site->url; + } + top_sites.push_back(std::move(site)); + } + return top_sites; +} + +} // namespace + +TopSitesAdapter::TopSitesAdapter( + std::unique_ptr most_visited_sites, + PrefService& pref_service) + : most_visited_sites_(std::move(most_visited_sites)), + pref_service_(pref_service) { + CHECK(most_visited_sites_); + most_visited_sites_->SetShortcutsVisible(GetTopSitesVisible()); + most_visited_sites_->EnableCustomLinks(GetListKind() == + mojom::TopSitesListKind::kCustom); + most_visited_sites_->AddMostVisitedURLsObserver( + this, ntp_tiles::kMaxNumMostVisited); +} + +TopSitesAdapter::~TopSitesAdapter() = default; + +bool TopSitesAdapter::GetTopSitesVisible() { + return pref_service_->GetBoolean(ntp_prefs::kNtpShortcutsVisible); +} + +void TopSitesAdapter::SetTopSitesVisible(bool visible) { + pref_service_->SetBoolean(ntp_prefs::kNtpShortcutsVisible, visible); + most_visited_sites_->SetShortcutsVisible(visible); +} + +mojom::TopSitesListKind TopSitesAdapter::GetListKind() { + bool use_most_visited = + pref_service_->GetBoolean(ntp_prefs::kNtpUseMostVisitedTiles); + return use_most_visited ? mojom::TopSitesListKind::kMostVisited + : mojom::TopSitesListKind::kCustom; +} + +void TopSitesAdapter::SetListKind(mojom::TopSitesListKind list_kind) { + bool use_most_visited = list_kind == mojom::TopSitesListKind::kMostVisited; + pref_service_->SetBoolean(ntp_prefs::kNtpUseMostVisitedTiles, + use_most_visited); + most_visited_sites_->EnableCustomLinks(!use_most_visited); +} + +void TopSitesAdapter::GetSites(GetSitesCallback callback) { + std::vector sites; + sites.reserve(current_sites_.size()); + for (auto& site : current_sites_) { + sites.push_back(site.Clone()); + } + std::move(callback).Run(std::move(sites)); +} + +void TopSitesAdapter::AddCustomSite(std::string_view url, + std::string_view title) { + most_visited_sites_->AddCustomLink(GURL(url), base::UTF8ToUTF16(title)); +} + +void TopSitesAdapter::UpdateCustomSite(std::string_view url, + std::string_view new_url, + std::string_view title) { + GURL updated_url(new_url); + + // If we are not changing the URL, then `most_visited_sites_` will expect the + // "new_url" parameter to be empty. + if (url == new_url) { + updated_url = GURL(); + } + + most_visited_sites_->UpdateCustomLink(GURL(url), updated_url, + base::UTF8ToUTF16(title)); +} + +void TopSitesAdapter::SetCustomSitePosition(std::string_view url, + int32_t position) { + most_visited_sites_->ReorderCustomLink(GURL(url), position); +} + +void TopSitesAdapter::RemoveCustomSite(std::string_view url) { + most_visited_sites_->DeleteCustomLink(GURL(url)); +} + +void TopSitesAdapter::UndoCustomSiteAction() { + most_visited_sites_->UndoCustomLinkAction(); +} + +void TopSitesAdapter::ExcludeMostVisitedSite(std::string_view url) { + most_visited_sites_->AddOrRemoveBlockedUrl(GURL(url), true); +} + +void TopSitesAdapter::IncludeMostVisitedTopSite(std::string_view url) { + most_visited_sites_->AddOrRemoveBlockedUrl(GURL(url), false); +} + +void TopSitesAdapter::SetSitesUpdatedCallback( + base::RepeatingCallback callback) { + sites_updated_callback_ = std::move(callback); +} + +void TopSitesAdapter::OnURLsAvailable( + const std::map& + sections) { + current_sites_ = TopSitesFromSections(sections); + if (sites_updated_callback_) { + sites_updated_callback_.Run(); + } +} + +void TopSitesAdapter::OnIconMadeAvailable(const GURL& site_url) {} + +} // namespace brave_new_tab diff --git a/browser/ui/webui/brave_new_tab/top_sites_adapter.h b/browser/ui/webui/brave_new_tab/top_sites_adapter.h new file mode 100644 index 000000000000..037203da5466 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/top_sites_adapter.h @@ -0,0 +1,78 @@ +// Copyright (c) 2025 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/. + +#ifndef BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_TOP_SITES_ADAPTER_H_ +#define BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_TOP_SITES_ADAPTER_H_ + +#include +#include +#include +#include + +#include "base/memory/raw_ref.h" +#include "brave/browser/ui/webui/brave_new_tab/new_tab_page.mojom.h" +#include "components/ntp_tiles/most_visited_sites.h" + +class PrefService; + +namespace brave_new_tab { + +// Provides access to the top sites API for use by the new tab page. +class TopSitesAdapter : public ntp_tiles::MostVisitedSites::Observer { + public: + TopSitesAdapter( + std::unique_ptr most_visited_sites, + PrefService& pref_service); + + TopSitesAdapter(const TopSitesAdapter&) = delete; + TopSitesAdapter& operator=(const TopSitesAdapter&) = delete; + + ~TopSitesAdapter() override; + + bool GetTopSitesVisible(); + void SetTopSitesVisible(bool visible); + + mojom::TopSitesListKind GetListKind(); + void SetListKind(mojom::TopSitesListKind list_kind); + + using GetSitesCallback = + base::OnceCallback)>; + + void GetSites(GetSitesCallback callback); + + void AddCustomSite(std::string_view url, std::string_view title); + + void UpdateCustomSite(std::string_view url, + std::string_view new_url, + std::string_view title); + + void SetCustomSitePosition(std::string_view url, int32_t position); + + void RemoveCustomSite(std::string_view url); + + void UndoCustomSiteAction(); + + void ExcludeMostVisitedSite(std::string_view url); + + void IncludeMostVisitedTopSite(std::string_view url); + + void SetSitesUpdatedCallback(base::RepeatingCallback callback); + + // MostVisitedSites::Observer: + void OnURLsAvailable( + const std::map& + sections) override; + void OnIconMadeAvailable(const GURL& site_url) override; + + private: + std::unique_ptr most_visited_sites_; + raw_ref pref_service_; + std::vector current_sites_; + base::RepeatingCallback sites_updated_callback_; +}; + +} // namespace brave_new_tab + +#endif // BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_TOP_SITES_ADAPTER_H_ diff --git a/browser/ui/webui/brave_new_tab/update_observer.cc b/browser/ui/webui/brave_new_tab/update_observer.cc new file mode 100644 index 000000000000..3b900ac92204 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/update_observer.cc @@ -0,0 +1,78 @@ +// Copyright (c) 2025 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/. + +#include "brave/browser/ui/webui/brave_new_tab/update_observer.h" + +#include + +#include "brave/browser/ntp_background/ntp_background_prefs.h" +#include "brave/components/brave_perf_predictor/common/pref_names.h" +#include "brave/components/brave_search_conversion/pref_names.h" +#include "brave/components/brave_vpn/common/buildflags/buildflags.h" +#include "brave/components/constants/pref_names.h" +#include "brave/components/ntp_background_images/common/pref_names.h" +#include "chrome/browser/ui/webui/new_tab_page/ntp_pref_names.h" +#include "chrome/common/pref_names.h" + +namespace brave_new_tab { + +UpdateObserver::UpdateObserver(PrefService& pref_service) { + pref_change_registrar_.Init(&pref_service); + AddPrefListener(ntp_background_images::prefs::kNewTabPageShowBackgroundImage, + Source::kBackgroundPrefs); + AddPrefListener(ntp_background_images::prefs:: + kNewTabPageShowSponsoredImagesBackgroundImage, + Source::kBackgroundPrefs); + 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); + AddPrefListener(ntp_prefs::kNtpShortcutsVisible, Source::kTopSitesPrefs); + AddPrefListener(ntp_prefs::kNtpUseMostVisitedTiles, Source::kTopSitesPrefs); + AddPrefListener(kNewTabPageShowClock, Source::kClockPrefs); + AddPrefListener(kNewTabPageClockFormat, Source::kClockPrefs); + AddPrefListener(kNewTabPageShowStats, Source::kShieldsStatsPrefs); + AddPrefListener(kAdsBlocked, Source::kShieldsStatsPrefs); + AddPrefListener(kTrackersBlocked, Source::kShieldsStatsPrefs); + AddPrefListener(brave_perf_predictor::prefs::kBandwidthSavedBytes, + Source::kShieldsStatsPrefs); + AddPrefListener(kNewTabPageShowBraveTalk, Source::kTalkPrefs); + AddPrefListener(kNewTabPageShowRewards, Source::kRewardsPrefs); + +#if BUILDFLAG(ENABLE_BRAVE_VPN) + AddPrefListener(kNewTabPageShowBraveVPN, Source::kVPNPrefs); +#endif +} + +UpdateObserver::~UpdateObserver() = default; + +void UpdateObserver::SetCallback( + base::RepeatingCallback callback) { + callback_ = std::move(callback); +} + +void UpdateObserver::OnUpdate(Source update_source) { + if (callback_) { + callback_.Run(update_source); + } +} + +void UpdateObserver::OnPrefChanged(Source update_kind, + const std::string& path) { + OnUpdate(update_kind); +} + +void UpdateObserver::AddPrefListener(const std::string& path, + Source update_source) { + pref_change_registrar_.Add( + path, base::BindRepeating(&UpdateObserver::OnPrefChanged, + weak_factory_.GetWeakPtr(), update_source)); +} + +} // namespace brave_new_tab diff --git a/browser/ui/webui/brave_new_tab/update_observer.h b/browser/ui/webui/brave_new_tab/update_observer.h new file mode 100644 index 000000000000..756115e3d9ae --- /dev/null +++ b/browser/ui/webui/brave_new_tab/update_observer.h @@ -0,0 +1,54 @@ +// Copyright (c) 2025 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/. + +#ifndef BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_UPDATE_OBSERVER_H_ +#define BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_UPDATE_OBSERVER_H_ + +#include + +#include "base/functional/callback.h" +#include "base/memory/weak_ptr.h" +#include "components/prefs/pref_change_registrar.h" + +class PrefService; + +namespace brave_new_tab { + +// Listens for changes to profile and system state that must be reflected on the +// new tab page. +class UpdateObserver { + public: + enum class Source { + kBackgroundPrefs, + kClockPrefs, + kSearchPrefs, + kTopSitesPrefs, + kShieldsStatsPrefs, + kTalkPrefs, + kVPNPrefs, + kRewardsPrefs + }; + + explicit UpdateObserver(PrefService& pref_service); + ~UpdateObserver(); + + UpdateObserver(const UpdateObserver&) = delete; + UpdateObserver& operator=(const UpdateObserver&) = delete; + + void SetCallback(base::RepeatingCallback callback); + + private: + void OnUpdate(Source update_source); + void OnPrefChanged(Source update_source, const std::string& path); + void AddPrefListener(const std::string& path, Source update_source); + + PrefChangeRegistrar pref_change_registrar_; + base::RepeatingCallback callback_; + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace brave_new_tab + +#endif // BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_UPDATE_OBSERVER_H_ diff --git a/browser/ui/webui/brave_new_tab/vpn_adapter.cc b/browser/ui/webui/brave_new_tab/vpn_adapter.cc new file mode 100644 index 000000000000..0dcab1b93959 --- /dev/null +++ b/browser/ui/webui/brave_new_tab/vpn_adapter.cc @@ -0,0 +1,60 @@ +// Copyright (c) 2025 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/. + +#include "brave/browser/ui/webui/brave_new_tab/vpn_adapter.h" + +#include "brave/components/constants/pref_names.h" +#include "chrome/browser/ui/browser_window/public/browser_window_features.h" +#include "chrome/browser/ui/browser_window/public/browser_window_interface.h" +#include "chrome/browser/ui/tabs/public/tab_interface.h" + +#if BUILDFLAG(ENABLE_BRAVE_VPN) +#include "brave/browser/ui/brave_vpn/brave_vpn_controller.h" +#include "brave/components/brave_vpn/browser/brave_vpn_service.h" +#endif + +namespace brave_new_tab { + +#if BUILDFLAG(ENABLE_BRAVE_VPN) + +VPNAdapter::VPNAdapter(tabs::TabInterface& tab, + brave_vpn::BraveVpnService* vpn_service) + : tab_(tab), vpn_service_(vpn_service) {} + +VPNAdapter::~VPNAdapter() = default; + +void VPNAdapter::ReloadPurchasedState() { + if (vpn_service_) { + vpn_service_->ReloadPurchasedState(); + } +} + +void VPNAdapter::OpenPanel() { + tab_->GetBrowserWindowInterface() + ->GetFeatures() + .GetBraveVPNController() + ->ShowBraveVPNBubble(/* show_select */ true); +} + +void VPNAdapter::OpenAccountPage(brave_vpn::mojom::ManageURLType url_type) { + tab_->GetBrowserWindowInterface() + ->GetFeatures() + .GetBraveVPNController() + ->OpenVPNAccountPage(url_type); +} + +void VPNAdapter::RecordWidgetUsage() { + if (vpn_service_) { + vpn_service_->brave_vpn_metrics()->RecordWidgetUsage(true); + } +} + +std::optional VPNAdapter::GetWidgetPrefName() { + return kNewTabPageShowBraveVPN; +} + +#endif + +} // namespace brave_new_tab diff --git a/browser/ui/webui/brave_new_tab/vpn_adapter.h b/browser/ui/webui/brave_new_tab/vpn_adapter.h new file mode 100644 index 000000000000..01ab57c6d1bd --- /dev/null +++ b/browser/ui/webui/brave_new_tab/vpn_adapter.h @@ -0,0 +1,71 @@ +// Copyright (c) 2025 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/. + +#ifndef BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_VPN_ADAPTER_H_ +#define BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_VPN_ADAPTER_H_ + +#include +#include + +#include "base/memory/raw_ptr.h" +#include "base/memory/raw_ref.h" +#include "brave/components/brave_vpn/common/buildflags/buildflags.h" +#include "brave/components/brave_vpn/common/mojom/brave_vpn.mojom.h" + +namespace tabs { +class TabInterface; +} + +#if BUILDFLAG(ENABLE_BRAVE_VPN) +namespace brave_vpn { +class BraveVpnService; +} +#endif + +namespace brave_new_tab { + +#if BUILDFLAG(ENABLE_BRAVE_VPN) + +class VPNAdapter { + public: + VPNAdapter(tabs::TabInterface& tab, brave_vpn::BraveVpnService* vpn_service); + ~VPNAdapter(); + + VPNAdapter(const VPNAdapter&) = delete; + VPNAdapter& operator=(const VPNAdapter&) = delete; + + void ReloadPurchasedState(); + void OpenPanel(); + void OpenAccountPage(brave_vpn::mojom::ManageURLType url_type); + void RecordWidgetUsage(); + std::optional GetWidgetPrefName(); + + private: + raw_ref tab_; + raw_ptr vpn_service_; +}; + +#else + +class VPNAdapter { + public: + VPNAdapter() = default; + ~VPNAdapter() = default; + + VPNAdapter(const VPNAdapter&) = delete; + VPNAdapter& operator=(const VPNAdapter&) = delete; + + void ReloadPurchasedState() {} + void OpenPanel() {} + void OpenAccountPage(brave_vpn::mojom::ManageURLType url_type) {} + void RecordWidgetUsage() {} + std::optional GetWidgetPrefName() { return std::nullopt; } +}; + +#endif + +} // namespace brave_new_tab + +#endif // BRAVE_BROWSER_UI_WEBUI_BRAVE_NEW_TAB_VPN_ADAPTER_H_ diff --git a/browser/ui/webui/brave_web_ui_controller_factory.cc b/browser/ui/webui/brave_web_ui_controller_factory.cc index 17cbef7ee78a..4764f29f835a 100644 --- a/browser/ui/webui/brave_web_ui_controller_factory.cc +++ b/browser/ui/webui/brave_web_ui_controller_factory.cc @@ -12,6 +12,7 @@ #include "base/memory/ptr_util.h" #include "base/no_destructor.h" #include "brave/browser/brave_ads/ads_service_factory.h" +#include "brave/browser/brave_browser_features.h" #include "brave/browser/brave_news/brave_news_controller_factory.h" #include "brave/browser/brave_rewards/rewards_util.h" #include "brave/browser/ethereum_remote_client/buildflags/buildflags.h" @@ -42,6 +43,7 @@ #if !BUILDFLAG(IS_ANDROID) #include "brave/browser/brave_wallet/brave_wallet_context_utils.h" +#include "brave/browser/ui/webui/brave_new_tab/new_tab_page_ui.h" #include "brave/browser/ui/webui/brave_news_internals/brave_news_internals_ui.h" #include "brave/browser/ui/webui/brave_wallet/wallet_page_ui.h" #include "brave/browser/ui/webui/new_tab_page/brave_new_tab_ui.h" @@ -151,6 +153,9 @@ WebUIController* NewWebUI(WebUI* web_ui, const GURL& url) { // WebUIConfig. Currently, we can't add both BravePrivateNewTabUI and // BraveNewTabUI configs in RegisterChromeWebUIConfigs because they use the // same origin (content::kChromeUIScheme + chrome::kChromeUINewTabHost). + if (base::FeatureList::IsEnabled(features::kUseUpdatedNTP)) { + return new brave_new_tab::NewTabPageUI(web_ui); + } return new BraveNewTabUI(web_ui, url.host()); #endif // !BUILDFLAG(IS_ANDROID) #if BUILDFLAG(ENABLE_TOR) diff --git a/browser/ui/webui/new_tab_page/DEPS b/browser/ui/webui/new_tab_page/DEPS index d72c8fe63bfa..afbfb77f9379 100644 --- a/browser/ui/webui/new_tab_page/DEPS +++ b/browser/ui/webui/new_tab_page/DEPS @@ -1,6 +1,6 @@ include_rules = [ "+brave/components/brave_ads/core/browser/service", - "+brave/components/brave_new_tab/resources", + "+brave/components/brave_new_tab_ui/resources", "+brave/components/brave_perf_predictor/common", "+brave/components/ntp_background_images/common", "+brave/components/time_period_storage", diff --git a/browser/ui/webui/new_tab_page/brave_new_tab_ui.cc b/browser/ui/webui/new_tab_page/brave_new_tab_ui.cc index 1548d5d9cb46..a947a7ff5eda 100644 --- a/browser/ui/webui/new_tab_page/brave_new_tab_ui.cc +++ b/browser/ui/webui/new_tab_page/brave_new_tab_ui.cc @@ -20,7 +20,7 @@ #include "brave/browser/ui/webui/new_tab_page/brave_new_tab_message_handler.h" #include "brave/browser/ui/webui/new_tab_page/brave_new_tab_page_handler.h" #include "brave/browser/ui/webui/new_tab_page/top_sites_message_handler.h" -#include "brave/components/brave_new_tab/resources/grit/brave_new_tab_generated_map.h" +#include "brave/components/brave_new_tab_ui/grit/brave_new_tab_ui_generated_map.h" #include "brave/components/brave_news/browser/brave_news_controller.h" #include "brave/components/brave_news/common/features.h" #include "brave/components/l10n/common/localization_util.h" @@ -74,7 +74,7 @@ BraveNewTabUI::BraveNewTabUI(content::WebUI* web_ui, const std::string& name) // Non blank NTP. content::WebUIDataSource* source = CreateAndAddWebUIDataSource( - web_ui, name, kBraveNewTabGenerated, IDR_BRAVE_NEW_TAB_HTML); + web_ui, name, kBraveNewTabUiGenerated, IDR_BRAVE_NEW_TAB_HTML); AddBackgroundColorToSource(source, web_contents); diff --git a/components/brave_new_tab_ui/BUILD.gn b/components/brave_new_tab_ui/BUILD.gn index d6a361bcba58..64a332ee4d93 100644 --- a/components/brave_new_tab_ui/BUILD.gn +++ b/components/brave_new_tab_ui/BUILD.gn @@ -9,7 +9,7 @@ import("//mojo/public/tools/bindings/mojom.gni") transpile_web_ui("brave_new_tab_ui") { entry_points = [ [ - "brave_new_tab", + "brave_new_tab_ui", rebase_path("brave_new_tab.tsx"), ] ] public_deps = [ @@ -23,12 +23,12 @@ transpile_web_ui("brave_new_tab_ui") { "//ui/webui/resources/cr_components/searchbox:mojo_bindings_js", ] } - resource_name = "brave_new_tab" + resource_name = "brave_new_tab_ui" } pack_web_resources("generated_resources") { - resource_name = "brave_new_tab" - output_dir = "$root_gen_dir/brave/components/brave_new_tab/resources" + resource_name = "brave_new_tab_ui" + output_dir = "$root_gen_dir/brave/components/brave_new_tab_ui" deps = [ ":brave_new_tab_ui" ] } diff --git a/components/brave_new_tab_ui/brave_new_tab.html b/components/brave_new_tab_ui/brave_new_tab.html index 8701520c371a..9eafbcd91b8e 100644 --- a/components/brave_new_tab_ui/brave_new_tab.html +++ b/components/brave_new_tab_ui/brave_new_tab.html @@ -13,7 +13,7 @@ - +