utils.jsx (10918B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ 4 import { useCallback, useEffect, useRef } from "react"; 5 6 const PREF_WEATHER_PLACEMENT = "weather.placement"; 7 const PREF_DAILY_BRIEF_SECTIONID = "discoverystream.dailyBrief.sectionId"; 8 const PREF_DAILY_BRIEF_ENABLED = "discoverystream.dailyBrief.enabled"; 9 const PREF_STORIES_ENABLED = "feeds.section.topstories"; 10 const PREF_SYSTEM_STORIES_ENABLED = "feeds.system.topstories"; 11 12 /** 13 * A custom react hook that sets up an IntersectionObserver to observe a single 14 * or list of elements and triggers a callback when the element comes into the viewport 15 * Note: The refs used should be an array type 16 * 17 * @function useIntersectionObserver 18 * @param {function} callback - The function to call when an element comes into the viewport 19 * @param {object} options - Options object passed to Intersection Observer: 20 * https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver#options 21 * @param {boolean} [isSingle = false] Boolean if the elements are an array or single element 22 * 23 * @returns {React.MutableRefObject} a ref containing an array of elements or single element 24 */ 25 function useIntersectionObserver(callback, threshold = 0.3) { 26 const elementsRef = useRef([]); 27 const triggeredElements = useRef(new WeakSet()); 28 useEffect(() => { 29 const observer = new IntersectionObserver( 30 entries => { 31 entries.forEach(entry => { 32 if ( 33 entry.isIntersecting && 34 !triggeredElements.current.has(entry.target) 35 ) { 36 triggeredElements.current.add(entry.target); 37 callback(entry.target); 38 observer.unobserve(entry.target); 39 } 40 }); 41 }, 42 { threshold } 43 ); 44 45 elementsRef.current.forEach(el => { 46 if (el && !triggeredElements.current.has(el)) { 47 observer.observe(el); 48 } 49 }); 50 51 // Cleanup function to disconnect observer on unmount 52 return () => observer.disconnect(); 53 }, [callback, threshold]); 54 55 return elementsRef; 56 } 57 58 /** 59 * Determines which column layout is active based on the screen width 60 * 61 * @param {number} screenWidth - The current window width (in pixels) 62 * @returns {string} The active column layout (e.g. "col-3", "col-2", "col-1") 63 */ 64 function getActiveColumnLayout(screenWidth) { 65 const breakpoints = [ 66 { min: 1374, column: "col-4" }, // $break-point-sections-variant 67 { min: 1122, column: "col-3" }, // $break-point-widest 68 { min: 724, column: "col-2" }, // $break-point-layout-variant 69 { min: 0, column: "col-1" }, // (default layout) 70 ]; 71 return breakpoints.find(bp => screenWidth >= bp.min).column; 72 } 73 74 /** 75 * Determines the active card size ("small", "medium", or "large") based on the screen width 76 * and class names applied to the card element at the time of an event (example: click) 77 * 78 * @param {number} screenWidth - The current window width (in pixels). 79 * @param {string | string[]} classNames - A string or array of class names applied to the sections card. 80 * @param {boolean[]} sectionsEnabled - If sections is not enabled, all cards are `medium-card` 81 * @param {number} flightId - Error ege case: This function should not be called on spocs, which have flightId 82 * @returns {"small-card" | "medium-card" | "large-card" | null} The active card type, or null if none is matched. 83 */ 84 function getActiveCardSize(screenWidth, classNames, sectionsEnabled, flightId) { 85 // Only applies to sponsored content 86 if (flightId) { 87 return "spoc"; 88 } 89 90 // Default layout only supports `medium-card` 91 if (!sectionsEnabled) { 92 // Missing arguments 93 return "medium-card"; 94 } 95 96 // Return null if no values are available 97 if (!screenWidth || !classNames) { 98 // Missing arguments 99 return null; 100 } 101 102 const classList = classNames.split(" "); 103 const cardTypes = ["small", "medium", "large"]; 104 105 // Determine which column is active based on the current screen width 106 const currColumnCount = getActiveColumnLayout(screenWidth); 107 108 // Match the card type for that column count 109 for (let type of cardTypes) { 110 const className = `${currColumnCount}-${type}`; 111 if (classList.includes(className)) { 112 // Special case: below $break-point-medium (610px), report `col-1-small` as medium 113 if ( 114 screenWidth < 610 && 115 currColumnCount === "col-1" && 116 type === "small" 117 ) { 118 return "medium-card"; 119 } 120 // Will be either "small-card", "medium-card", or "large-card" 121 return `${type}-card`; 122 } 123 } 124 125 return null; 126 } 127 128 const CONFETTI_VARS = [ 129 "--color-red-40", 130 "--color-yellow-40", 131 "--color-purple-40", 132 "--color-blue-40", 133 "--color-green-40", 134 ]; 135 136 /** 137 * Custom hook to animate a confetti burst. 138 * 139 * @param {number} count Number of particles 140 * @param {number} spread spread of confetti 141 * @returns {[React.RefObject<HTMLCanvasElement>, () => void]} 142 */ 143 function useConfetti(count = 80, spread = Math.PI / 3) { 144 // avoid errors from about:home cache 145 const prefersReducedMotion = 146 typeof window !== "undefined" && 147 typeof window.matchMedia === "function" && 148 window.matchMedia("(prefers-reduced-motion: reduce)").matches; 149 150 let colors; 151 // if in abouthome cache, getComputedStyle will not be available 152 if (typeof getComputedStyle === "function") { 153 const styles = getComputedStyle(document.documentElement); 154 colors = CONFETTI_VARS.map(variable => 155 styles.getPropertyValue(variable).trim() 156 ); 157 } else { 158 colors = ["#fa5e75", "#de9600", "#c671eb", "#3f94ff", "#37b847"]; 159 } 160 161 const canvasRef = useRef(null); 162 const particlesRef = useRef([]); 163 const animationFrameRef = useRef(0); 164 165 // initialize/reset pool 166 const initializeConfetti = useCallback( 167 (width, height) => { 168 const centerX = width / 2; 169 const centerY = height; 170 const pool = particlesRef.current; 171 172 // Create or overwrite each particle’s initial state 173 for (let i = 0; i < count; i++) { 174 const angle = Math.PI / 2 + (Math.random() - 0.5) * spread; 175 const cos = Math.cos(angle); 176 const sin = Math.sin(angle); 177 const color = colors[Math.floor(Math.random() * colors.length)]; 178 179 pool[i] = { 180 x: centerX + (Math.random() - 0.5) * 40, 181 y: centerY, 182 cos, 183 sin, 184 velocity: Math.random() * 6 + 6, 185 gravity: 0.3, 186 decay: 0.96, 187 size: 8, 188 color, 189 life: 0, 190 maxLife: 100, 191 tilt: Math.random() * Math.PI * 2, 192 tiltSpeed: Math.random() * 0.2 + 0.05, 193 }; 194 } 195 }, 196 [count, spread, colors] 197 ); 198 199 // Core animation loop — updates physics & renders each frame 200 const animateParticles = useCallback(canvas => { 201 const context = canvas.getContext("2d"); 202 const { width, height } = canvas; 203 const pool = particlesRef.current; 204 205 // Clear the entire canvas each frame 206 context.clearRect(0, 0, width, height); 207 208 let anyAlive = false; 209 for (let particle of pool) { 210 if (particle.life < particle.maxLife) { 211 anyAlive = true; 212 213 // update each particles physics: position, velocity decay, gravity, tilt, lifespan 214 particle.velocity *= particle.decay; 215 particle.x += particle.cos * particle.velocity; 216 particle.y -= particle.sin * particle.velocity; 217 particle.y += particle.gravity; 218 particle.tilt += particle.tiltSpeed; 219 particle.life += 1; 220 } 221 222 // Draw: apply alpha, transform & draw a rotated, scaled square 223 const alphaValue = 1 - particle.life / particle.maxLife; 224 const scaleY = Math.sin(particle.tilt); 225 226 context.globalAlpha = alphaValue; 227 context.setTransform(1, 0, 0, 1, particle.x, particle.y); 228 context.rotate(Math.PI / 4); 229 context.scale(1, scaleY); 230 231 context.fillStyle = particle.color; 232 context.fillRect( 233 -particle.size / 2, 234 -particle.size / 2, 235 particle.size, 236 particle.size 237 ); 238 239 // reset each particle 240 context.setTransform(1, 0, 0, 1, 0, 0); 241 context.globalAlpha = 1; 242 } 243 244 if (anyAlive) { 245 // continue the animation 246 animationFrameRef.current = requestAnimationFrame(() => { 247 animateParticles(canvas); 248 }); 249 } else { 250 cancelAnimationFrame(animationFrameRef.current); 251 context.clearRect(0, 0, width, height); 252 } 253 }, []); 254 255 // Resets and starts a new confetti animation 256 const fireConfetti = useCallback(() => { 257 if (prefersReducedMotion) { 258 return; 259 } 260 const canvas = canvasRef?.current; 261 if (canvas) { 262 cancelAnimationFrame(animationFrameRef.current); 263 initializeConfetti(canvas.width, canvas.height); 264 animateParticles(canvas); 265 } 266 }, [initializeConfetti, animateParticles, prefersReducedMotion]); 267 268 return [canvasRef, fireConfetti]; 269 } 270 271 function selectWeatherPlacement(state) { 272 const prefs = state.Prefs.values || {}; 273 274 // Intent: only placed in section if explicitly requested 275 const placementPref = 276 prefs.trainhopConfig?.dailyBriefing?.placement || 277 prefs[PREF_WEATHER_PLACEMENT]; 278 279 if (placementPref === "header" || !placementPref) { 280 return "header"; 281 } 282 283 const sections = 284 state.DiscoveryStream.feeds.data[ 285 "https://merino.services.mozilla.com/api/v1/curated-recommendations" 286 ]?.data.sections ?? []; 287 // check the following prefs to make sure weather is elligible to be placed in sections 288 // 1. The daily brieifng section must be availible and in the top position 289 // 2. That the daily briefing section has not been blocked 290 // 3. That reccomended stories are truned on 291 // Otherwise it should be placed in the header 292 const pocketEnabled = 293 prefs[PREF_STORIES_ENABLED] && prefs[PREF_SYSTEM_STORIES_ENABLED]; 294 const sectionPersonalization = 295 state.DiscoveryStream?.sectionPersonalization || {}; 296 const dailyBriefEnabled = 297 prefs.trainhopConfig?.dailyBriefing?.enabled || 298 prefs[PREF_DAILY_BRIEF_ENABLED]; 299 const sectionId = 300 prefs.trainhopConfig?.dailyBriefing?.sectionId || 301 prefs[PREF_DAILY_BRIEF_SECTIONID]; 302 const notBlocked = sectionId && !sectionPersonalization[sectionId]?.isBlocked; 303 let filteredSections = sections.filter( 304 section => !sectionPersonalization[section.sectionKey]?.isBlocked 305 ); 306 const foundSection = filteredSections.find( 307 section => section.sectionKey === sectionId 308 ); 309 const isTopSection = 310 foundSection?.receivedRank === 0 || 311 filteredSections.indexOf(foundSection) === 0; 312 313 const eligible = 314 pocketEnabled && 315 dailyBriefEnabled && 316 sectionId && 317 notBlocked && 318 isTopSection; 319 return eligible ? "section" : "header"; 320 } 321 322 export { 323 useIntersectionObserver, 324 getActiveCardSize, 325 getActiveColumnLayout, 326 useConfetti, 327 selectWeatherPlacement, 328 };