Skip to content

Commit b8cae70

Browse files
author
Dre Grant
committed
Bug 2061876 - Add count-up celebration to the Privacy New Tab widget. r=fchasen
Per HNT-2845/Figma, the Privacy widget should react when its blocked-tracker count moves: a brief sparkle plus a count-up on any +10, and a longer, denser sparkle for the bigger earned moments. Today the number changes silently. Differential Revision: https://phabricator.services.mozilla.com/D317213
1 parent fd4104f commit b8cae70

18 files changed

Lines changed: 1758 additions & 81 deletions

File tree

browser/extensions/newtab/common/Actions.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ for (const type of [
250250
"WIDGETS_OPT_IN",
251251
"WIDGETS_PICTURE_SET_WALLPAPER",
252252
"WIDGETS_PRIVACY_CTA",
253+
"WIDGETS_PRIVACY_MARK_CELEBRATED",
253254
"WIDGETS_PRIVACY_UPDATE",
254255
"WIDGETS_SPORTS_CHANGE_FOLLOWED_ONLY",
255256
"WIDGETS_SPORTS_CHANGE_LIVE_INDEX",

browser/extensions/newtab/common/Reducers.sys.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,10 @@ export const INITIAL_STATE = {
311311
cta: null,
312312
// When set, the count readout shows "{countCeiling}+" (the daily-cap render).
313313
countCeiling: null,
314+
// Pending count-up celebration awarded by PrivacyFeed, or null. Shaped
315+
// { awardedAt, fromCount, toCount }, plus `forcedTier` when the debug
316+
// pref made it; `awardedAt` doubles as its id.
317+
celebration: null,
314318
},
315319
};
316320

browser/extensions/newtab/common/WidgetsRegistry.mjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,12 @@ export const PREF_PRIVACY_BLANK_CHANCE = "widgets.privacy.blankChance";
121121
export const PREF_PRIVACY_SHOW_VPN_MESSAGES = "widgets.privacy.showVpnMessages";
122122
export const PREF_PRIVACY_FORCE_MESSAGE_ID = "widgets.privacy.forceMessageId";
123123
export const PREF_PRIVACY_MESSAGE_STATE = "widgets.privacy.messageState";
124+
export const PREF_PRIVACY_CELEBRATION_THRESHOLD =
125+
"widgets.privacy.celebrationThreshold";
126+
export const PREF_PRIVACY_CELEBRATION_STATE =
127+
"widgets.privacy.celebrationState";
128+
export const PREF_PRIVACY_FORCE_CELEBRATION =
129+
"widgets.privacy.forceCelebration";
124130
export const PREF_WIDGETS_CROSSWORD_ENABLED = "widgets.crossword.enabled";
125131
export const PREF_CROSSWORD_SIZE = "widgets.crossword.size";
126132
export const PREF_WIDGETS_SYSTEM_CROSSWORD_ENABLED =
@@ -574,6 +580,27 @@ export function resolvePrivacyShowVpnMessages(prefs) {
574580
return !!prefs[PREF_PRIVACY_SHOW_VPN_MESSAGES];
575581
}
576582

583+
/**
584+
* Resolves how far the blocked-tracker count must climb before the count-up
585+
* celebration fires. Priority: trainhopConfig > pref > 10 (HNT-2845).
586+
*
587+
* @param {object} prefs - current pref values from the Redux store
588+
* @returns {number}
589+
*/
590+
export function resolvePrivacyCelebrationThreshold(prefs) {
591+
const DEFAULT = 10;
592+
const trainhop = prefs.trainhopConfig?.widgets?.privacyCelebrationThreshold;
593+
const raw =
594+
typeof trainhop === "number"
595+
? trainhop
596+
: prefs[PREF_PRIVACY_CELEBRATION_THRESHOLD];
597+
// A zero or negative threshold would fire on every refresh.
598+
if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 1) {
599+
return DEFAULT;
600+
}
601+
return raw;
602+
}
603+
577604
/**
578605
* Returns the list of widgets to disable when "hide all" is triggered.
579606
* A widget is included if it has no sidebar variant OR if it is currently

browser/extensions/newtab/content-src/components/Widgets/Privacy/Privacy.jsx

Lines changed: 240 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
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";
77
import { useSelector, batch } from "react-redux";
88
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
99
import { useIntersectionObserver, useSizeSubmenu } from "../../../lib/utils";
@@ -13,11 +13,27 @@ import {
1313
resolvePrivacyDisplayCount,
1414
} from "common/WidgetsRegistry.mjs";
1515
import { 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

1721
const 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+
2137
const PRIVACY_ENTRY = WIDGET_REGISTRY.find(w => w.id === "privacy");
2238

2339
const 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

4693
function 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

Comments
 (0)