Skip to content

Commit 2b42cb6

Browse files
committed
Address burn count down
1 parent 7252eab commit 2b42cb6

5 files changed

Lines changed: 124 additions & 33 deletions

File tree

special-pages/pages/new-tab/app/protections/components/Protections.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { ProtectionsHeadingLegacy } from './ProtectionsHeadingLegacy';
1919
* @param {object} props
2020
* @param {Expansion} props.expansion
2121
* @param {import("@preact/signals").Signal<number>} props.blockedCountSignal
22+
* @param {import("@preact/signals").Signal<boolean>} [props.skipAnimationSignal]
2223
* @param {ProtectionsConfig['feed']} props.feed
2324
* @param {(feed: ProtectionsConfig['feed']) => void} props.setFeed
2425
* @param {import("preact").ComponentChild} [props.children]
@@ -29,6 +30,7 @@ export function Protections({
2930
expansion = 'expanded',
3031
children,
3132
blockedCountSignal,
33+
skipAnimationSignal,
3234
feed,
3335
toggle,
3436
setFeed,
@@ -64,6 +66,7 @@ export function Protections({
6466
) : (
6567
<ProtectionsHeading
6668
blockedCountSignal={blockedCountSignal}
69+
skipAnimationSignal={skipAnimationSignal}
6770
onToggle={toggle}
6871
expansion={expansion}
6972
canExpand={true}

special-pages/pages/new-tab/app/protections/components/ProtectionsConsumer.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ export function ProtectionsConsumer() {
3939
*/
4040
function ProtectionsReadyState({ data, config }) {
4141
const { toggle, setFeed } = useContext(ProtectionsContext);
42-
const blockedCountSignal = useBlockedCount(data.totalCount);
42+
const { signal: blockedCountSignal, skipAnimation: skipAnimationSignal } = useBlockedCount(data.totalCount);
4343
const totalCookiePopUpsBlockedSignal = useCookiePopUpsBlockedCount(data.totalCookiePopUpsBlocked);
4444

4545
return (
4646
<Protections
4747
blockedCountSignal={blockedCountSignal}
48+
skipAnimationSignal={skipAnimationSignal}
4849
expansion={config.expansion}
4950
toggle={toggle}
5051
feed={config.feed}

special-pages/pages/new-tab/app/protections/components/ProtectionsHeading.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useRef, useEffect } from 'preact/hooks';
1616
* @param {object} props
1717
* @param {import('../../../types/new-tab.ts').Expansion} props.expansion
1818
* @param {import("@preact/signals").Signal<number>} props.blockedCountSignal
19+
* @param {import("@preact/signals").Signal<boolean>} [props.skipAnimationSignal]
1920
* @param {boolean} props.canExpand
2021
* @param {() => void} props.onToggle
2122
* @param {import('preact').ComponentProps<'button'>} [props.buttonAttrs]
@@ -25,6 +26,7 @@ export function ProtectionsHeading({
2526
expansion,
2627
canExpand,
2728
blockedCountSignal,
29+
skipAnimationSignal,
2830
onToggle,
2931
buttonAttrs = {},
3032
totalCookiePopUpsBlockedSignal,
@@ -43,8 +45,13 @@ export function ProtectionsHeading({
4345
: 0;
4446

4547
// Animate both tracker count and cookie pop-ups count when counterContainer is in viewport
46-
const animatedTrackersBlocked = useAnimatedCount(totalTrackersBlocked, counterContainerRef);
47-
const animatedCookiePopUpsBlocked = useAnimatedCount(totalCookiePopUpsBlocked, counterContainerRef);
48+
// Skip animation when burning all data (skipAnimationSignal is true)
49+
// Pass the signal directly so useAnimatedCount can reactively read its value
50+
// Both counts use the same skipAnimationSignal to ensure consistent behavior:
51+
// - When burning all: both skip animation and go directly to empty state
52+
// - When burning a single domain: both animate normally to show updated counts
53+
const animatedTrackersBlocked = useAnimatedCount(totalTrackersBlocked, counterContainerRef, skipAnimationSignal);
54+
const animatedCookiePopUpsBlocked = useAnimatedCount(totalCookiePopUpsBlocked, counterContainerRef, skipAnimationSignal);
4855

4956
// Subscribe to scroll message
5057
useEffect(() => {

special-pages/pages/new-tab/app/protections/components/ProtectionsProvider.js

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,56 @@ export function useService() {
9292

9393
/**
9494
* @param {number} initial
95-
* @return {import("@preact/signals").Signal<number>}
95+
* @return {{signal: import("@preact/signals").Signal<number>, skipAnimation: import("@preact/signals").Signal<boolean>}}
9696
*/
9797
export function useBlockedCount(initial) {
9898
const service = useContext(ProtectionsServiceContext);
99+
const ntp = useMessaging();
99100
const signal = useSignal(initial);
101+
const skipAnimationSignal = useSignal(false);
102+
const burnCompleteTimeRef = useRef(/** @type {number | null} */ (null));
103+
104+
// Track burn complete events to detect "burn all" scenario
105+
useEffect(() => {
106+
if (!ntp) return;
107+
return ntp.messaging.subscribe('activity_onBurnComplete', () => {
108+
// Mark that we should skip animation if next update goes to 0
109+
burnCompleteTimeRef.current = Date.now();
110+
});
111+
}, [ntp]);
112+
100113
// @todo jingram possibly refactor to include full object
101114
useSignalEffect(() => {
102115
return service?.onData((evt) => {
103-
signal.value = evt.data.totalCount;
116+
const newValue = evt.data.totalCount;
117+
const previousValue = signal.value;
118+
119+
// If transitioning to 0 and we just had a burn complete (within 1 second),
120+
// this is likely a "burn all" operation - skip animation and go directly to empty state
121+
if (newValue === 0 && previousValue > 0 && burnCompleteTimeRef.current !== null) {
122+
const timeSinceBurn = Date.now() - burnCompleteTimeRef.current;
123+
if (timeSinceBurn < 1000) {
124+
// Set skipAnimation flag before updating the signal value
125+
// This ensures useAnimatedCount immediately sets to 0 without animating
126+
skipAnimationSignal.value = true;
127+
signal.value = newValue;
128+
// Reset after animation would have completed to allow normal behavior for future updates
129+
setTimeout(() => {
130+
skipAnimationSignal.value = false;
131+
burnCompleteTimeRef.current = null;
132+
}, 500);
133+
return;
134+
}
135+
}
136+
137+
// Normal update (including single domain burns) - allow animation for both counts
138+
// This ensures both tracker count and cookie pop-ups count animate when burning a single domain
139+
skipAnimationSignal.value = false;
140+
signal.value = newValue;
104141
});
105142
});
106-
return signal;
143+
144+
return { signal, skipAnimation: skipAnimationSignal };
107145
}
108146

109147
/**

special-pages/pages/new-tab/app/protections/utils/useAnimatedCount.js

Lines changed: 69 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
2+
import { useSignalEffect } from '@preact/signals';
23
import { animateCount } from './animateCount.js';
34

45
/**
@@ -20,16 +21,30 @@ import { animateCount } from './animateCount.js';
2021
* @param {number} targetValue - The target value to animate to
2122
* @param {import('preact').RefObject<HTMLElement>} [elementRef] - Optional ref
2223
* to element for viewport detection
24+
* @param {boolean | import("@preact/signals").Signal<boolean>} [skipAnimation] - If true or signal value is true, skip animation and immediately set to target value
2325
* @returns {number} The current animated value
2426
*
2527
* @todo IDEAL SOLUTION: Native code should send a message (e.g.,
2628
* 'ntp_becameVisible') when the NTP webview becomes visible to the user. This
2729
* would be more reliable than JavaScript-only detection. We could subscribe to
2830
* this message and trigger animation when received.
2931
*/
30-
export function useAnimatedCount(targetValue, elementRef) {
32+
export function useAnimatedCount(targetValue, elementRef, skipAnimation = false) {
3133
// Initialize to 0 so first render triggers percentage-based animation from spec
3234
const [animatedValue, setAnimatedValue] = useState(0);
35+
36+
// Track skipAnimation reactively if it's a signal
37+
const isSignal = typeof skipAnimation === 'object' && 'value' in skipAnimation;
38+
const [shouldSkipAnimation, setShouldSkipAnimation] = useState(
39+
isSignal ? skipAnimation.value : skipAnimation
40+
);
41+
42+
// Reactively track signal value changes (always call hook, but only track if it's a signal)
43+
useSignalEffect(() => {
44+
if (isSignal) {
45+
setShouldSkipAnimation(skipAnimation.value);
46+
}
47+
});
3348

3449
// Track current animated value to enable smooth incremental updates
3550
// Initialize to 0 so first animation uses spec's percentage-based starting point
@@ -121,22 +136,36 @@ export function useAnimatedCount(targetValue, elementRef) {
121136
wasVisibleRef.current = isCurrentlyVisible;
122137

123138
if (isCurrentlyVisible) {
124-
// Determine starting value for animation
125-
let startValue = animatedValueRef.current;
126-
127-
// If we're returning to NTP and the target value has changed, animate from last seen value
128-
if (isReturningToNTP && lastSeenValueRef.current !== null && lastSeenValueRef.current !== targetValue) {
129-
startValue = lastSeenValueRef.current;
130-
// Reset animation state to allow re-animation
131-
hasAnimatedRef.current = false;
132-
}
139+
// If skipAnimation is true (e.g., after burn all), immediately set to target value
140+
// This skips the countdown animation and goes directly to empty state (when targetValue is 0)
141+
if (shouldSkipAnimation) {
142+
// Cancel any ongoing animation immediately
143+
cancelAnimation();
144+
// Immediately set to target value without animation
145+
// When targetValue is 0, this will trigger the empty state display
146+
setAnimatedValue(targetValue);
147+
animatedValueRef.current = targetValue;
148+
hasAnimatedRef.current = true;
149+
// Update last seen value so it's correct for future animations
150+
lastSeenValueRef.current = targetValue;
151+
} else {
152+
// Determine starting value for animation
153+
let startValue = animatedValueRef.current;
133154

134-
// Animate from start value to target
135-
cancelAnimation = animateCount(targetValue, updateAnimatedCount, undefined, startValue);
136-
hasAnimatedRef.current = true;
155+
// If we're returning to NTP and the target value has changed, animate from last seen value
156+
if (isReturningToNTP && lastSeenValueRef.current !== null && lastSeenValueRef.current !== targetValue) {
157+
startValue = lastSeenValueRef.current;
158+
// Reset animation state to allow re-animation
159+
hasAnimatedRef.current = false;
160+
}
137161

138-
// After animation starts, update last seen to target (will be updated as animation progresses)
139-
// This ensures next time we hide, we save the correct final value
162+
// Animate from start value to target
163+
cancelAnimation = animateCount(targetValue, updateAnimatedCount, undefined, startValue);
164+
hasAnimatedRef.current = true;
165+
166+
// After animation starts, update last seen to target (will be updated as animation progresses)
167+
// This ensures next time we hide, we save the correct final value
168+
}
140169
} else {
141170
// Page is not visible
142171
if (wasVisible) {
@@ -164,19 +193,32 @@ export function useAnimatedCount(targetValue, elementRef) {
164193
wasVisibleRef.current = isNowVisible;
165194

166195
if (isNowVisible) {
167-
// Determine starting value
168-
let startValue = animatedValueRef.current;
196+
// If skipAnimation is true (e.g., after burn all), immediately set to target value
197+
// This skips the countdown animation and goes directly to empty state (when targetValue is 0)
198+
if (shouldSkipAnimation) {
199+
cancelAnimation();
200+
// Immediately set to target value without animation
201+
// When targetValue is 0, this will trigger the empty state display
202+
setAnimatedValue(targetValue);
203+
animatedValueRef.current = targetValue;
204+
hasAnimatedRef.current = true;
205+
// Update last seen value so it's correct for future animations
206+
lastSeenValueRef.current = targetValue;
207+
} else {
208+
// Determine starting value
209+
let startValue = animatedValueRef.current;
210+
211+
// If returning to NTP and value changed, animate from last seen
212+
if (isReturningToNTPNow && lastSeenValueRef.current !== null && lastSeenValueRef.current !== targetValue) {
213+
startValue = lastSeenValueRef.current;
214+
hasAnimatedRef.current = false;
215+
}
169216

170-
// If returning to NTP and value changed, animate from last seen
171-
if (isReturningToNTPNow && lastSeenValueRef.current !== null && lastSeenValueRef.current !== targetValue) {
172-
startValue = lastSeenValueRef.current;
173-
hasAnimatedRef.current = false;
217+
// Page became visible and element is in viewport - start animation
218+
cancelAnimation();
219+
cancelAnimation = animateCount(targetValue, updateAnimatedCount, undefined, startValue);
220+
hasAnimatedRef.current = true;
174221
}
175-
176-
// Page became visible and element is in viewport - start animation
177-
cancelAnimation();
178-
cancelAnimation = animateCount(targetValue, updateAnimatedCount, undefined, startValue);
179-
hasAnimatedRef.current = true;
180222
} else if (document.visibilityState === 'hidden') {
181223
// Page became hidden - save current value and cancel animation
182224
cancelAnimation();
@@ -195,7 +237,7 @@ export function useAnimatedCount(targetValue, elementRef) {
195237
cancelAnimation();
196238
document.removeEventListener('visibilitychange', handleVisibilityChange);
197239
};
198-
}, [targetValue, updateAnimatedCount, isInViewport]);
240+
}, [targetValue, updateAnimatedCount, isInViewport, shouldSkipAnimation]);
199241

200242
return animatedValue;
201243
}

0 commit comments

Comments
 (0)