Skip to content

Commit 50e6954

Browse files
committed
[NTP Next] Add News
1 parent 7d68211 commit 50e6954

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+4268
-20
lines changed

browser/resources/brave_new_tab_page_refresh/brave_new_tab_page.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import { SearchProvider } from './context/search_context'
1313
import { TopSitesProvider } from './context/top_sites_context'
1414
import { VpnProvider } from './context/vpn_context'
1515
import { RewardsProvider } from './context/rewards_context'
16+
import { NewsProvider } from './context/news_context'
1617

17-
import { App } from './components/app'
18+
import { App, NewsApp } from './components/app'
1819

1920
setIconBasePath('chrome://resources/brave-icons')
2021

@@ -26,7 +27,9 @@ function AppProvider(props: { children: React.ReactNode }) {
2627
<TopSitesProvider name='topSites'>
2728
<VpnProvider name='vpn'>
2829
<RewardsProvider name='rewards'>
29-
{props.children}
30+
<NewsProvider name='news'>
31+
{props.children}
32+
</NewsProvider>
3033
</RewardsProvider>
3134
</VpnProvider>
3235
</TopSitesProvider>
@@ -37,7 +40,15 @@ function AppProvider(props: { children: React.ReactNode }) {
3740
}
3841

3942
createRoot(document.getElementById('root')!).render(
40-
<AppProvider>
41-
<App />
42-
</AppProvider>
43+
isNewsOnlyURL() ?
44+
<NewsProvider name='news'>
45+
<NewsApp />
46+
</NewsProvider> :
47+
<AppProvider>
48+
<App />
49+
</AppProvider>
4350
)
51+
52+
function isNewsOnlyURL() {
53+
return /^\/news(\/|$)/i.test(location.pathname)
54+
}

browser/resources/brave_new_tab_page_refresh/components/app.style.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,38 @@
66
import { color, font } from '@brave/leo/tokens/css/variables'
77
import { scoped } from '../lib/scoped_css'
88

9-
const narrowBreakpoint = '900px'
9+
export const narrowBreakpoint = '900px'
10+
export const threeColumnBreakpoint = '1310px'
1011

1112
export const style = scoped.css`
1213
& {
1314
--search-transition-duration: 120ms;
1415
}
1516
17+
@keyframes scroll-fade {
18+
from {
19+
background: rgba(0, 0, 0, 0);
20+
backdrop-filter: blur(0);
21+
}
22+
50% {
23+
backdrop-filter: blur(0);
24+
}
25+
to {
26+
background: rgba(0, 0, 0, 0.65);
27+
backdrop-filter: blur(32px);
28+
}
29+
}
30+
31+
.background-filter {
32+
position: fixed;
33+
inset: 0;
34+
z-index: 1;
35+
36+
animation: linear scroll-fade both;
37+
animation-timeline: scroll();
38+
animation-range: 0px 100vh;
39+
}
40+
1641
.top-controls {
1742
position: absolute;
1843
inset-block-start: 4px;
@@ -160,10 +185,14 @@ export const style = scoped.css`
160185
align-self: center;
161186
flex-basis: auto;
162187
display: flex;
163-
flex-direction: column;
188+
flex-direction: column-reverse;
164189
}
165190
}
166191
192+
.news-container {
193+
position: relative;
194+
z-index: 1;
195+
}
167196
`
168197

169198
style.passthrough.css`
@@ -248,4 +277,40 @@ style.passthrough.css`
248277
}
249278
}
250279
}
280+
281+
.skeleton {
282+
--self-animation-color: rgba(0, 0, 0, 0.1);
283+
284+
background: rgba(255, 255, 255, 0.25);
285+
position: relative;
286+
overflow: hidden;
287+
opacity: .7;
288+
289+
animation: skeleton-fade-in 1s ease-in-out both 250ms;
290+
291+
@media (prefers-color-scheme: dark) {
292+
--self-animation-color: rgba(255, 255, 255, 0.1);
293+
}
294+
}
295+
296+
.skeleton:after {
297+
content: '';
298+
position: absolute;
299+
transform: translateX(-100%);
300+
inset: 0;
301+
background: linear-gradient(
302+
90deg, transparent, var(--self-animation-color), transparent);
303+
animation: skeleton-background-cycle 2s linear 0.5s infinite;
304+
}
305+
306+
@keyframes skeleton-fade-in {
307+
0% { opacity: 0; }
308+
100% { opacity: .7; }
309+
}
310+
311+
@keyframes skeleton-background-cycle {
312+
0% { transform: translateX(-100%); }
313+
50% { transform: translateX(100%); }
314+
100% { transform: translateX(100%); }
315+
}
251316
`

browser/resources/brave_new_tab_page_refresh/components/app.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,25 @@ import { SettingsModal, SettingsView } from './settings/settings_modal'
1313
import { TopSites } from './top_sites/top_sites'
1414
import { Clock } from './common/clock'
1515
import { WidgetStack } from './widgets/widget_stack'
16+
import { NewsFeed } from './news/news_feed'
1617

17-
import { style } from './app.style'
18+
import { style, threeColumnBreakpoint } from './app.style'
19+
20+
const threeColumnQuery = window.matchMedia(`(width > ${threeColumnBreakpoint})`)
1821

1922
export function App() {
2023
const [settingsView, setSettingsView] =
2124
React.useState<SettingsView | null>(null)
2225

26+
const [threeColumnWidth, setThreeColumnWidth] =
27+
React.useState(threeColumnQuery.matches)
28+
29+
React.useEffect(() => {
30+
const listener = () => setThreeColumnWidth(threeColumnQuery.matches)
31+
threeColumnQuery.addEventListener('change', listener)
32+
return () => threeColumnQuery.removeEventListener('change', listener)
33+
}, [])
34+
2335
React.useEffect(() => {
2436
const params = new URLSearchParams(location.search)
2537
const settingsArg = params.get('openSettings')
@@ -33,6 +45,7 @@ export function App() {
3345
return (
3446
<div data-css-scope={style.scope}>
3547
<Background />
48+
<div className='background-filter allow-background-pointer-events' />
3649
<div className='top-controls'>
3750
<button
3851
className='clock'
@@ -61,10 +74,20 @@ export function App() {
6174
<BackgroundCaption />
6275
</div>
6376
<div className='widget-container'>
64-
<WidgetStack name='left' tabs={['stats']} />
77+
{
78+
threeColumnWidth ?
79+
<>
80+
<WidgetStack name='left' tabs={['stats']} />
81+
<WidgetStack name='center' tabs={['news']} />
82+
</> :
83+
<WidgetStack name='left' tabs={['stats', 'news']} />
84+
}
6585
<WidgetStack name='right' tabs={['vpn', 'rewards', 'talk']} />
6686
</div>
6787
</main>
88+
<div className='news-container'>
89+
<NewsFeed />
90+
</div>
6891
<SettingsModal
6992
isOpen={settingsView !== null}
7093
initialView={settingsView}
@@ -73,3 +96,11 @@ export function App() {
7396
</div>
7497
)
7598
}
99+
100+
export function NewsApp() {
101+
return (
102+
<div data-css-scope={style.scope}>
103+
<NewsFeed standalone />
104+
</div>
105+
)
106+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* Copyright (c) 2025 The Brave Authors. All rights reserved.
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
4+
* You can obtain one at https://mozilla.org/MPL/2.0/. */
5+
6+
import * as React from 'react'
7+
8+
import { PluralStringProxyImpl } from 'chrome://resources/js/plural_string_proxy.js'
9+
import { PluralStringKey } from '../../lib/strings'
10+
11+
interface Props {
12+
stringKey: PluralStringKey
13+
count: number
14+
}
15+
16+
// A React component that displays a plural string.
17+
export function PluralString(props: Props) {
18+
return usePluralString(props.stringKey, props.count)
19+
}
20+
21+
function usePluralString(key: PluralStringKey, count: number) {
22+
const [value, setValue] = React.useState('')
23+
24+
React.useEffect(() => {
25+
if (typeof count !== 'number') {
26+
setValue('')
27+
return
28+
}
29+
let canUpdate = true
30+
PluralStringProxyImpl
31+
.getInstance()
32+
.getPluralString(key, count)
33+
.then((newValue) => {
34+
if (canUpdate) {
35+
setValue(newValue)
36+
}
37+
})
38+
return () => { canUpdate = false }
39+
}, [key, count])
40+
41+
return value
42+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* Copyright (c) 2025 The Brave Authors. All rights reserved.
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
4+
* You can obtain one at https://mozilla.org/MPL/2.0/. */
5+
6+
import * as React from 'react'
7+
8+
interface Props {
9+
onVisible: () => void
10+
rootMargin?: string
11+
}
12+
13+
export function VisibilityTracker(props: Props) {
14+
const ref = React.useRef<HTMLDivElement>(null)
15+
16+
React.useEffect(() => {
17+
if (!ref.current) {
18+
return
19+
}
20+
21+
const observer = new IntersectionObserver((entries) => {
22+
for (const entry of entries) {
23+
if (entry.intersectionRatio > 0) {
24+
props.onVisible()
25+
return
26+
}
27+
}
28+
}, { rootMargin: props.rootMargin || '0px 0px 0px 0px' })
29+
30+
observer.observe(ref.current)
31+
return () => { observer.disconnect() }
32+
}, [props.onVisible, props.rootMargin])
33+
34+
return <div ref={ref} />
35+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/* Copyright (c) 2025 The Brave Authors. All rights reserved.
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
4+
* You can obtain one at https://mozilla.org/MPL/2.0/. */
5+
6+
import { color, font } from '@brave/leo/tokens/css/variables'
7+
import { scoped } from '../../lib/scoped_css'
8+
9+
export const style = scoped.css`
10+
a {
11+
text-decoration: none;
12+
color: ${color.text.primary};
13+
display: flex;
14+
flex-direction: column;
15+
gap: 4px;
16+
}
17+
18+
.hero img {
19+
margin-bottom: 12px;
20+
width: 100%;
21+
height: 269px;
22+
object-fit: cover;
23+
object-position: center top;
24+
border-radius: 6px;
25+
}
26+
27+
.metadata {
28+
--leo-icon-size: 12px;
29+
30+
padding: 6px 0;
31+
display: flex;
32+
align-items: center;
33+
gap: 4px;
34+
font: ${font.xSmall.regular};
35+
color: rgba(255, 255, 255, 0.5);
36+
}
37+
38+
.actions {
39+
--leo-icon-size: 16px;
40+
41+
flex: 1 1 auto;
42+
display: flex;
43+
justify-content: flex-end;
44+
45+
&:hover {
46+
color: #fff;
47+
}
48+
}
49+
50+
.article-menu-anchor {
51+
anchor-name: --news-article-menu-button;
52+
}
53+
54+
.article-menu {
55+
position-anchor: --news-article-menu-button;
56+
position-area: block-end span-inline-start;
57+
position-try-fallbacks: flip-block;
58+
}
59+
60+
.preview {
61+
font: ${font.default.semibold};
62+
color: #fff;
63+
display: flex;
64+
align-items: center;
65+
justify-content: space-between;
66+
gap: 16px;
67+
68+
img {
69+
min-width: 96px;
70+
width: 96px;
71+
height: 64px;
72+
object-fit: cover;
73+
object-position: center top;
74+
border-radius: 6px;
75+
}
76+
}
77+
`

0 commit comments

Comments
 (0)