33 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44
55// eslint-disable-next-line no-unused-vars
6- import React , { useCallback , useRef } from "react" ;
6+ import React , { useCallback , useLayoutEffect , useRef , useState } from "react" ;
77import { useSelector , batch } from "react-redux" ;
88import { actionCreators as ac , actionTypes as at } from "common/Actions.mjs" ;
99import { useIntersectionObserver , useSizeSubmenu } from "../../../lib/utils" ;
@@ -13,11 +13,27 @@ import {
1313 resolvePrivacyDisplayCount ,
1414} from "common/WidgetsRegistry.mjs" ;
1515import { MoveSubmenu } from "../MoveSubmenu" ;
16+ import { WidgetCelebration , CelebrationSparkles } from "../WidgetCelebration" ;
17+ import { useWidgetCelebration } from "../useWidgetCelebration" ;
18+ import { useCountUp } from "../useCountUp" ;
19+ import { usePageVisible } from "../usePageVisible" ;
1620
1721const USER_ACTION_TYPES = {
1822 CHANGE_SIZE : "change_size" ,
1923} ;
2024
25+ // Per design: the brief sparkle rides ordinary +10 count-ups and the smaller
26+ // earned moments; the longer, denser one is reserved for the count milestones
27+ // and the daily cap. Durations are tiered in _Privacy.scss.
28+ const CELEBRATION_TIERS = {
29+ brief : { sparkleCount : 12 , sparkleStaggerMs : 65 } ,
30+ major : { sparkleCount : 24 , sparkleStaggerMs : 60 } ,
31+ } ;
32+
33+ // Count-up duration. Slower than the 750ms prototype so the number is
34+ // readable while it climbs.
35+ const COUNT_UP_DURATION_MS = 1100 ;
36+
2137const PRIVACY_ENTRY = WIDGET_REGISTRY . find ( w => w . id === "privacy" ) ;
2238
2339const ICON_BASE_URL = "chrome://newtab/content/data/content/assets/" ;
@@ -32,16 +48,47 @@ const ICON_ASSETS = {
3248 kit : "widget-privacy-kit.svg" ,
3349} ;
3450
51+ // The kit head-tilt loops on its own (CSS animation inside the SVG), so it is
52+ // swapped in only during an earned moment. Its reduced-motion guard lives in
53+ // the asset: loaded via <img>, the page's JS check can't reach it.
54+ const KIT_ANIMATED_ASSET = "kit-circle-animated.svg" ;
55+
56+ // The longer, denser sparkle is reserved for the count milestones — the
57+ // "100+ type" moments.
58+ const MAJOR_CELEBRATION_CATEGORIES = new Set ( [
59+ "dailyCap" ,
60+ "milestoneWeek" ,
61+ "milestoneMonth" ,
62+ "milestoneYear" ,
63+ "milestoneTotal" ,
64+ ] ) ;
65+
66+ // Every scheduler "earned moment" fires a celebration and tilts the kit. A
67+ // first block happens minutes into a new profile and a streak is a days count,
68+ // so those two get the brief sparkle rather than the loud one above.
69+ const CELEBRATION_CATEGORIES = new Set ( [
70+ ...MAJOR_CELEBRATION_CATEGORIES ,
71+ "firstProtection" ,
72+ "streak" ,
73+ ] ) ;
74+
3575// Renders a widget icon by icon key. The wrapper div is the alignment hook.
36- const privacyImage = iconKey => (
37- < div className = "privacy-image" >
38- < img
39- className = "privacy-image-icon"
40- src = { `${ ICON_BASE_URL } ${ ICON_ASSETS [ iconKey ] || ICON_ASSETS . shieldCheck } ` }
41- alt = ""
42- />
43- </ div >
44- ) ;
76+ // `animated` swaps the kit for its head-tilt variant during a celebration.
77+ const privacyImage = ( iconKey , animated = false ) => {
78+ const asset =
79+ animated && iconKey === "kit"
80+ ? KIT_ANIMATED_ASSET
81+ : ICON_ASSETS [ iconKey ] || ICON_ASSETS . shieldCheck ;
82+ return (
83+ < div className = "privacy-image" >
84+ < img
85+ className = "privacy-image-icon"
86+ src = { `${ ICON_BASE_URL } ${ asset } ` }
87+ alt = ""
88+ />
89+ </ div >
90+ ) ;
91+ } ;
4592
4693function Privacy ( { dispatch, widgetsMayBeMaximized, widgetEnabledMap } ) {
4794 const prefs = useSelector ( state => state . Prefs . values ) ;
@@ -60,21 +107,44 @@ function Privacy({ dispatch, widgetsMayBeMaximized, widgetEnabledMap }) {
60107 const initialized = privacyData ?. initialized ?? false ;
61108
62109 // Message decision chosen by PrivacyFeed's selector (Bug 2050954).
63- const { variant, messageId, icon, countArg, cta, countCeiling } =
110+ const { variant, messageId, category , icon, countArg, cta, countCeiling } =
64111 privacyData ?? { } ;
65112 const isLarge = widgetSize === "large" ;
66113
114+ // widgetRef holds an array for the intersection observer, hence the separate
115+ // element ref for the celebration's own measurements.
116+ const celebrationRef = useRef ( null ) ;
117+ const {
118+ celebrationFrame,
119+ celebrationId,
120+ completeCelebration,
121+ isCelebrating,
122+ triggerCelebration,
123+ } = useWidgetCelebration ( celebrationRef ) ;
124+ // displayValue is the resting count except while a celebration counts up.
125+ const { countUp, displayValue, hold, release } = useCountUp (
126+ trackersToday ,
127+ COUNT_UP_DURATION_MS
128+ ) ;
129+
130+ // A celebration is one-shot, so playing it on a preloaded tab would spend it
131+ // before the user could see it. Hold until the tab is actually shown.
132+ const isPageVisible = usePageVisible ( ) ;
133+
67134 // Normally show the real count, only ceiling the readout at "{cap}+"
68135 // (default 999) so it stays a tidy few characters. On the daily-cap render
69136 // the selector sets countCeiling (100), so that one load shows "100+"; the
70137 // next load clears it and the real number returns.
71138 const displayCap = resolvePrivacyDisplayCount ( prefs ) ;
72- let displayCount = `${ trackersToday } ` ;
73- if ( typeof countCeiling === "number" ) {
74- displayCount = `${ countCeiling } +` ;
75- } else if ( trackersToday > displayCap ) {
76- displayCount = `${ displayCap } +` ;
77- }
139+ const formatCount = value => {
140+ if ( typeof countCeiling === "number" ) {
141+ return `${ countCeiling } +` ;
142+ }
143+ return value > displayCap ? `${ displayCap } +` : `${ value } ` ;
144+ } ;
145+ const displayCount = formatCount ( displayValue ) ;
146+ // Same readout without the animation, for the screen-reader copy.
147+ const stableCount = formatCount ( trackersToday ) ;
78148
79149 // trackersToday === 0 is the sole trigger for the empty layout. It must not
80150 // also key off `variant === "empty"`: a SYSTEM_TICK refreshes the count
@@ -96,6 +166,121 @@ function Privacy({ dispatch, widgetsMayBeMaximized, widgetEnabledMap }) {
96166 const iconBesideCount = ! isEmptyState && ! ( isTip && isLarge ) ;
97167 const iconInTip = isTip && isLarge ;
98168
169+ // Count-up celebration (HNT-2845), awarded by PrivacyFeed once the count has
170+ // climbed by the threshold. Independent of the message decision: it animates
171+ // whatever number is on screen. The border ring is reserved for the
172+ // daily-cap render, which is the one the design gives a gradient border.
173+ const celebration = privacyData ?. celebration ?? null ;
174+ const playedCelebrationRef = useRef ( null ) ;
175+ const playedMomentRef = useRef ( null ) ;
176+ // Debug override (widgets.privacy.forceCelebration) wins when set, so QA can
177+ // see each tier without engineering the scheduler state that produces it.
178+ const forcedTier = celebration ?. forcedTier ?? null ;
179+ const isEarnedMoment = CELEBRATION_CATEGORIES . has ( category ) ;
180+ // Count milestones get the longer sparkle; everything else the brief one.
181+ const isMajorMoment = forcedTier
182+ ? forcedTier !== "brief"
183+ : MAJOR_CELEBRATION_CATEGORIES . has ( category ) ;
184+ const celebrationTier = isMajorMoment
185+ ? CELEBRATION_TIERS . major
186+ : CELEBRATION_TIERS . brief ;
187+ // The gradient ring is narrower still: only the design's 100+ card has one.
188+ const showRing = forcedTier
189+ ? forcedTier === "cap"
190+ : typeof countCeiling === "number" ;
191+ // Every earned moment tilts the kit. A plain +10 lands on an info message,
192+ // whose icon is never the kit, so `isCelebrating` alone can't swap it.
193+ const tiltKit = isEarnedMoment ;
194+
195+ // Snapshot taken when the animation starts: `category` and `countCeiling`
196+ // can move under it and swap the tier or pull the ring mid-flight.
197+ const [ playingCelebration , setPlayingCelebration ] = useState ( null ) ;
198+ const activeCelebration = playingCelebration ?? {
199+ isMajor : isMajorMoment ,
200+ showRing,
201+ tier : celebrationTier ,
202+ } ;
203+
204+ // Two things can start a celebration, and either alone is enough:
205+ // 1. a +10 count-up award from PrivacyFeed (HNT-2845), and
206+ // 2. an earned moment from the scheduler, which the design celebrates in
207+ // its own right (streak, milestones, daily cap, first block).
208+ // Each earned moment carries its own messageId, so that's the dedupe key.
209+ // Layout effect, not a passive one: the resting value is the *final* count,
210+ // so starting after paint shows one frame of it before the count-up resets
211+ // to `fromCount` and climbs back — a visible backward jump.
212+ useLayoutEffect ( ( ) => {
213+ const awardedAt = celebration ?. awardedAt ?? null ;
214+ const isNewAward = awardedAt && playedCelebrationRef . current !== awardedAt ;
215+ const isNewMoment = isEarnedMoment && playedMomentRef . current !== messageId ;
216+
217+ if ( ! isNewAward && ! isNewMoment ) {
218+ return ;
219+ }
220+
221+ // Preloaded tab: leave the award and the moment unplayed, and hold the
222+ // pre-award number so revealing the tab doesn't flash the final count.
223+ if ( ! isPageVisible ) {
224+ if ( isNewAward ) {
225+ hold ( celebration . fromCount ) ;
226+ }
227+ return ;
228+ }
229+
230+ if ( isEarnedMoment ) {
231+ playedMomentRef . current = messageId ;
232+ }
233+
234+ if ( isNewAward ) {
235+ playedCelebrationRef . current = awardedAt ;
236+ // Acknowledge even when the animation is skipped (reduced motion), so
237+ // the award doesn't stay pending and replay on the next tab.
238+ dispatch (
239+ ac . AlsoToMain ( {
240+ type : at . WIDGETS_PRIVACY_MARK_CELEBRATED ,
241+ data : awardedAt ,
242+ } )
243+ ) ;
244+ }
245+
246+ const isPlaying = triggerCelebration ( ) ;
247+ if ( isPlaying ) {
248+ setPlayingCelebration ( {
249+ isMajor : isMajorMoment ,
250+ showRing,
251+ tier : celebrationTier ,
252+ } ) ;
253+ }
254+
255+ if ( isPlaying && isNewAward ) {
256+ countUp ( celebration . fromCount , celebration . toCount ) ;
257+ } else {
258+ // Nothing will animate the readout — reduced motion, or a moment with no
259+ // award — so drop any hold taken while the tab was hidden.
260+ release ( ) ;
261+ }
262+ } , [
263+ celebration ,
264+ celebrationTier ,
265+ countUp ,
266+ dispatch ,
267+ hold ,
268+ isEarnedMoment ,
269+ isMajorMoment ,
270+ isPageVisible ,
271+ messageId ,
272+ release ,
273+ showRing ,
274+ triggerCelebration ,
275+ ] ) ;
276+
277+ // Drop the snapshot with the animation, so the next run can't paint a frame
278+ // of the previous one's tier.
279+ const handleCelebrationComplete = useCallback ( ( ) => {
280+ setPlayingCelebration ( null ) ;
281+ completeCelebration ( ) ;
282+ } , [ completeCelebration ] ) ;
283+
99284 const handleIntersection = useCallback ( ( ) => {
100285 if ( impressionFired . current ) {
101286 return ;
@@ -256,9 +441,14 @@ function Privacy({ dispatch, widgetsMayBeMaximized, widgetEnabledMap }) {
256441 initialized && isEmptyState ? " is-empty" : ""
257442 } ${ initialized && isTip ? " has-tip-msg" : "" } ${
258443 initialized && isStreak ? " has-streak" : ""
444+ } ${
445+ isCelebrating && activeCelebration . isMajor
446+ ? " is-major-celebration"
447+ : ""
259448 } `}
260449 ref = { el => {
261450 widgetRef . current = [ el ] ;
451+ celebrationRef . current = el ;
262452 } }
263453 >
264454 < div className = "privacy-title-wrapper" >
@@ -326,8 +516,27 @@ function Privacy({ dispatch, widgetsMayBeMaximized, widgetEnabledMap }) {
326516 < div className = "privacy-count-number-wrapper" >
327517 { /* The single icon sits beside the count, except in the large
328518 tip layout where it moves into the tip. */ }
329- { iconBesideCount && privacyImage ( icon || "shieldCheck" ) }
330- < span className = "privacy-count-number" > { displayCount } </ span >
519+ { iconBesideCount &&
520+ privacyImage ( icon || "shieldCheck" , tiltKit ) }
521+ { /* The count-up mutates this text every frame, and
522+ newtab-privacy-trackers-blocked-today uses $count for
523+ plural selection only — so this span is the sole place
524+ the number exists. Hide the animating copy and expose a
525+ stable one, or AT can read a transient value. */ }
526+ < span className = "privacy-count-number" aria-hidden = "true" >
527+ { displayCount }
528+ </ span >
529+ < span className = "privacy-count-number-a11y" >
530+ { stableCount }
531+ </ span >
532+ { isCelebrating ? (
533+ < CelebrationSparkles
534+ classNamePrefix = "privacy-celebration"
535+ celebrationId = { celebrationId }
536+ count = { activeCelebration . tier . sparkleCount }
537+ staggerMs = { activeCelebration . tier . sparkleStaggerMs }
538+ />
539+ ) : null }
331540 </ div >
332541
333542 < div className = "privacy-count-text" >
@@ -356,7 +565,7 @@ function Privacy({ dispatch, widgetsMayBeMaximized, widgetEnabledMap }) {
356565 < >
357566 < hr className = "privacy-divider" />
358567 < div className = "privacy-tip" >
359- { iconInTip && privacyImage ( icon || "shieldCheck" ) }
568+ { iconInTip && privacyImage ( icon || "shieldCheck" , tiltKit ) }
360569 < div className = "privacy-tip-content" >
361570 { messageEl ( "privacy-tip-message" ) }
362571 { ctaButton }
@@ -369,6 +578,17 @@ function Privacy({ dispatch, widgetsMayBeMaximized, widgetEnabledMap }) {
369578 </ >
370579 ) ) }
371580 </ div >
581+
582+ { isCelebrating && celebrationFrame ? (
583+ < WidgetCelebration
584+ classNamePrefix = "privacy-celebration"
585+ celebrationFrame = { celebrationFrame }
586+ celebrationId = { celebrationId }
587+ onComplete = { handleCelebrationComplete }
588+ showBorder = { false }
589+ showRing = { activeCelebration . showRing }
590+ />
591+ ) : null }
372592 </ article >
373593 ) ;
374594}
0 commit comments