tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 };