• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / ae8b0561-4499-448a-ab50-352afb4b27c2

30 Oct 2025 02:51PM UTC coverage: 88.761% (+0.04%) from 88.726%
ae8b0561-4499-448a-ab50-352afb4b27c2

push

circleci

web-flow
Merge pull request #6015 from mozilla/MPP-4477-old-pricing-grid-canada

MPP-4477 - fix(pricing-grid): show updated pricing grid for canada

2906 of 3925 branches covered (74.04%)

Branch coverage included in aggregate %.

18126 of 19770 relevant lines covered (91.68%)

11.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.24
/frontend/src/components/layout/navigation/whatsnew/WhatsNewMenu.tsx
1
import {
2
  forwardRef,
3
  HTMLAttributes,
4
  ReactNode,
5
  RefObject,
6
  useRef,
7
} from "react";
9✔
8
import {
9
  DismissButton,
10
  FocusScope,
11
  mergeProps,
12
  OverlayContainer,
13
  useButton,
14
  useDialog,
15
  useModal,
16
  useOverlay,
17
  useOverlayPosition,
18
  useOverlayTrigger,
19
} from "react-aria";
9✔
20
import { useOverlayTriggerState } from "react-stately";
9✔
21
import { StaticImageData } from "next/image";
22
import styles from "./WhatsNewMenu.module.scss";
9✔
23
import SizeLimitHero from "./images/size-limit-hero-10mb.svg";
9✔
24
import SizeLimitIcon from "./images/size-limit-icon-10mb.svg";
9✔
25
import SignBackInHero from "./images/sign-back-in-hero.svg";
9✔
26
import SignBackInIcon from "./images/sign-back-in-icon.svg";
9✔
27
import ForwardSomeHero from "./images/forward-some-hero.svg";
9✔
28
import ForwardSomeIcon from "./images/forward-some-icon.svg";
9✔
29
import aliasToMaskHero from "./images/alias-to-mask-hero.svg";
9✔
30
import aliasToMaskIcon from "./images/alias-to-mask-icon.svg";
9✔
31
import TrackerRemovalHero from "./images/tracker-removal-hero.svg";
9✔
32
import TrackerRemovalIcon from "./images/tracker-removal-icon.svg";
9✔
33
import PremiumSwedenHero from "./images/premium-expansion-sweden-hero.svg";
9✔
34
import PremiumSwedenIcon from "./images/premium-expansion-sweden-icon.svg";
9✔
35
import PremiumEuExpansionHero from "./images/eu-expansion-hero.svg";
9✔
36
import PremiumEuExpansionIcon from "./images/eu-expansion-icon.svg";
9✔
37
import PremiumFinlandHero from "./images/premium-expansion-finland-hero.svg";
9✔
38
import PremiumFinlandIcon from "./images/premium-expansion-finland-icon.svg";
9✔
39
import PhoneMaskingHero from "./images/phone-masking-hero.svg";
9✔
40
import PhoneMaskingIcon from "./images/phone-masking-icon.svg";
9✔
41
import HolidayPromo2023Icon from "./images/holiday-promo-2023-news-icon.svg";
9✔
42
import HolidayPromo2023Hero from "./images/holiday-promo-2023-news-hero.svg";
9✔
43
import BundleHero from "./images/bundle-promo-hero.svg";
9✔
44
import BundleIcon from "./images/bundle-promo-icon.svg";
9✔
45
import FirefoxIntegrationHero from "./images/firefox-integration-hero.svg";
9✔
46
import FirefoxIntegrationIcon from "./images/firefox-integration-icon.svg";
9✔
47
import MailingListHero from "./images/mailing-list-hero.svg";
9✔
48
import MailingListIcon from "./images/mailing-list-icon.svg";
9✔
49
import { WhatsNewContent } from "./WhatsNewContent";
9✔
50
import {
51
  DismissalData,
52
  useLocalDismissal,
53
} from "../../../../hooks/localDismissal";
9✔
54
import { ProfileData } from "../../../../hooks/api/profile";
55
import { WhatsNewDashboard } from "./WhatsNewDashboard";
9✔
56
import { useAddonData } from "../../../../hooks/addon";
9✔
57
import { isUsingFirefox } from "../../../../functions/userAgent";
9✔
58
import { getLocale } from "../../../../functions/getLocale";
9✔
59
import { RuntimeData } from "../../../../hooks/api/types";
60
import { isFlagActive } from "../../../../functions/waffle";
9✔
61
import {
62
  getBundlePrice,
63
  getPeriodicalPremiumSubscribeLink,
64
  isBundleAvailableInCountry,
65
  isPeriodicalPremiumAvailableInCountry,
66
  isPhonesAvailableInCountry,
67
} from "../../../../functions/getPlan";
9✔
68
import Link from "next/link";
9✔
69
import { GiftIcon } from "../../../Icons";
9✔
70
import { useGaEvent } from "../../../../hooks/gaEvent";
9✔
71
import { useL10n } from "../../../../hooks/l10n";
9✔
72
import { VisuallyHidden } from "../../../VisuallyHidden";
9✔
73
import { useOverlayBugWorkaround } from "../../../../hooks/overlayBugWorkaround";
9✔
74
import { useGaViewPing } from "../../../../hooks/gaViewPing";
9✔
75
import { useUtmApplier } from "../../../../hooks/utmApplier";
9✔
76

77
export type WhatsNewEntry = {
78
  title: string;
79
  snippet: string;
80
  content: ReactNode;
81
  icon: StaticImageData;
82
  dismissal: DismissalData;
83
  /**
84
   * This is used to automatically archive entries of a certain age
85
   */
86
  announcementDate: {
87
    year: number;
88
    // Spelled out just to make sure it's clear we're not using 0-based months.
89
    // Thanks, JavaScript...
90
    month: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
91
    day: number;
92
  };
93
};
94

95
export type Props = {
96
  profile: ProfileData;
97
  style: string;
98
  runtimeData?: RuntimeData;
99
};
100

101
type CtaProps = {
102
  link?: string;
103
  label: string;
104
  subscribed?: boolean;
105
};
106

107
const CtaLinkButton = (props: CtaProps) => {
9✔
108
  const hasSubscription = props.subscribed;
×
109

110
  return (
111
    <>
112
      {!hasSubscription ? (
×
113
        <Link href="/premium#pricing" legacyBehavior>
114
          <span className={styles.cta}>{props.label}</span>
115
        </Link>
116
      ) : null}
117
    </>
118
  );
119
};
120

121
export const WhatsNewMenu = (props: Props) => {
89✔
122
  const l10n = useL10n();
123✔
123
  const gaEvent = useGaEvent();
123✔
124

125
  const triggerState = useOverlayTriggerState({
123✔
126
    onOpenChange(isOpen) {
127
      gaEvent({
1✔
128
        category: "News",
129
        action: isOpen ? "Open" : "Close",
1!
130
        label: "header-nav",
131
      });
132
    },
133
  });
134

135
  const triggerRef = useRef<HTMLButtonElement>(null);
123✔
136
  const overlayRef = useRef<HTMLDivElement>(null);
123✔
137
  const addonData = useAddonData();
123✔
138

139
  const entries: WhatsNewEntry[] = [
123✔
140
    {
141
      title: l10n.getString("whatsnew-feature-size-limit-heading"),
142
      snippet: l10n.getString("whatsnew-feature-size-limit-snippet-var", {
143
        size: 10,
144
        unit: "MB",
145
      }),
146
      content: (
147
        <WhatsNewContent
148
          description={l10n.getString(
149
            "whatsnew-feature-size-limit-description-var",
150
            {
151
              size: 10,
152
              unit: "MB",
153
            },
154
          )}
155
          heading={l10n.getString("whatsnew-feature-size-limit-heading")}
156
          image={SizeLimitHero}
157
          videos={{
158
            // Unfortunately video files cannot currently be imported, so make
159
            // sure these files are present in /public. See
160
            // https://github.com/vercel/next.js/issues/35248
161
            "video/webm; codecs='vp9'":
162
              "/animations/whatsnew/size-limit-hero-10mb.webm",
163
            "video/mp4": "/animations/whatsnew/size-limit-hero-10mb.mp4",
164
          }}
165
        />
166
      ),
167
      icon: SizeLimitIcon,
168
      dismissal: useLocalDismissal(
169
        `whatsnew-feature_size-limit_${props.profile.id}`,
170
      ),
171
      announcementDate: {
172
        year: 2022,
173
        month: 3,
174
        day: 1,
175
      },
176
    },
177
  ];
178

179
  const forwardSomeEntry: WhatsNewEntry = {
123✔
180
    title: l10n.getString("whatsnew-feature-forward-some-heading"),
181
    snippet: l10n.getString("whatsnew-feature-forward-some-snippet"),
182
    content: (
183
      <WhatsNewContent
184
        description={l10n.getString(
185
          "whatsnew-feature-forward-some-description",
186
        )}
187
        heading={l10n.getString("whatsnew-feature-forward-some-heading")}
188
        image={ForwardSomeHero}
189
      />
190
    ),
191
    icon: ForwardSomeIcon,
192
    dismissal: useLocalDismissal(
193
      `whatsnew-feature_sign-back-in_${props.profile.id}`,
194
    ),
195
    announcementDate: {
196
      year: 2022,
197
      month: 3,
198
      day: 1,
199
    },
200
  };
201
  if (props.profile.has_premium) {
123✔
202
    entries.push(forwardSomeEntry);
2✔
203
  }
204

205
  const signBackInEntry: WhatsNewEntry = {
123✔
206
    title: l10n.getString("whatsnew-feature-sign-back-in-heading"),
207
    snippet: l10n.getString("whatsnew-feature-sign-back-in-snippet"),
208
    content: (
209
      <WhatsNewContent
210
        description={l10n.getString(
211
          "whatsnew-feature-sign-back-in-description",
212
        )}
213
        heading={l10n.getString("whatsnew-feature-sign-back-in-heading")}
214
        image={SignBackInHero}
215
      />
216
    ),
217
    icon: SignBackInIcon,
218
    dismissal: useLocalDismissal(
219
      `whatsnew-feature_sign-back-in_${props.profile.id}`,
220
    ),
221
    announcementDate: {
222
      year: 2022,
223
      month: 2,
224
      day: 1,
225
    },
226
  };
227
  if (addonData.present && isUsingFirefox()) {
123!
228
    entries.push(signBackInEntry);
×
229
  }
230

231
  const aliasToMask: WhatsNewEntry = {
123✔
232
    title: l10n.getString("whatsnew-feature-alias-to-mask-heading"),
233
    snippet: l10n.getString("whatsnew-feature-alias-to-mask-snippet"),
234
    content: (
235
      <WhatsNewContent
236
        description={l10n.getString(
237
          "whatsnew-feature-alias-to-mask-description",
238
        )}
239
        heading={l10n.getString("whatsnew-feature-alias-to-mask-heading")}
240
        image={aliasToMaskHero}
241
      />
242
    ),
243
    icon: aliasToMaskIcon,
244
    dismissal: useLocalDismissal(
245
      `whatsnew-feature_alias-to-mask_${props.profile.id}`,
246
    ),
247
    announcementDate: {
248
      year: 2022,
249
      month: 4,
250
      day: 19,
251
    },
252
  };
253
  // Not all localisations transitioned from "alias" to "mask", so only show this
254
  // announcement for those of which we _know_ did:
255
  if (
123!
256
    [
257
      "en",
258
      "en-gb",
259
      "nl",
260
      "fy-nl",
261
      "zh-tw",
262
      "es-es",
263
      "es-mx",
264
      "de",
265
      "pt-br",
266
      "sv-se",
267
      "el",
268
      "hu",
269
      "sk",
270
      "skr",
271
      "uk",
272
    ].includes(getLocale(l10n).toLowerCase())
273
  ) {
274
    entries.push(aliasToMask);
123✔
275
  }
276

277
  const premiumInSweden: WhatsNewEntry = {
123✔
278
    title: l10n.getString("whatsnew-feature-premium-expansion-sweden-heading"),
279
    snippet: l10n.getString("whatsnew-feature-premium-expansion-snippet"),
280
    content: (
281
      <WhatsNewContent
282
        description={l10n.getString(
283
          "whatsnew-feature-premium-expansion-description",
284
        )}
285
        heading={l10n.getString(
286
          "whatsnew-feature-premium-expansion-sweden-heading",
287
        )}
288
        image={PremiumSwedenHero}
289
      />
290
    ),
291
    icon: PremiumSwedenIcon,
292
    dismissal: useLocalDismissal(
293
      `whatsnew-feature_premium-expansion-sweden_${props.profile.id}`,
294
    ),
295
    announcementDate: {
296
      year: 2022,
297
      month: 5,
298
      day: 17,
299
    },
300
  };
301
  if (
123!
302
    props.runtimeData?.PERIODICAL_PREMIUM_PLANS.country_code.toLowerCase() ===
123!
303
      "se" &&
304
    !props.profile.has_premium
305
  ) {
306
    entries.push(premiumInSweden);
×
307
  }
308

309
  const premiumInFinland: WhatsNewEntry = {
123✔
310
    title: l10n.getString("whatsnew-feature-premium-expansion-finland-heading"),
311
    snippet: l10n.getString("whatsnew-feature-premium-expansion-snippet"),
312
    content: (
313
      <WhatsNewContent
314
        description={l10n.getString(
315
          "whatsnew-feature-premium-expansion-description",
316
        )}
317
        heading={l10n.getString(
318
          "whatsnew-feature-premium-expansion-finland-heading",
319
        )}
320
        image={PremiumFinlandHero}
321
      />
322
    ),
323
    icon: PremiumFinlandIcon,
324
    dismissal: useLocalDismissal(
325
      `whatsnew-feature_premium-expansion-finland_${props.profile.id}`,
326
    ),
327
    announcementDate: {
328
      year: 2022,
329
      month: 5,
330
      day: 17,
331
    },
332
  };
333
  if (
123!
334
    props.runtimeData?.PERIODICAL_PREMIUM_PLANS.country_code.toLowerCase() ===
123!
335
      "fi" &&
336
    !props.profile.has_premium
337
  ) {
338
    entries.push(premiumInFinland);
×
339
  }
340

341
  const applyUtmParams = useUtmApplier();
123✔
342
  // Check if yearlyPlanLink should be generated based on runtimeData and availability
343
  const yearlyPlanLink =
344
    props.runtimeData &&
123✔
345
    isPeriodicalPremiumAvailableInCountry(props.runtimeData)
346
      ? applyUtmParams(
347
          getPeriodicalPremiumSubscribeLink(props.runtimeData, "yearly"),
348
        )
349
      : undefined;
350

351
  const yearlyPlanRefWithCoupon = `${yearlyPlanLink}&coupon=HOLIDAY20&utm_source=relay.firefox.com&utm_medium=whatsnew-announcement&utm_campaign=relay-holiday-promo-2023`;
123✔
352
  const getYearlyPlanBtnRef = useGaViewPing({
123✔
353
    category: "Holiday Promo News CTA",
354
    label: "holiday-promo-2023-news-cta",
355
  });
356

357
  const holidayPromo2023: WhatsNewEntry = {
123✔
358
    title: l10n.getString("whatsnew-holiday-promo-2023-news-heading"),
359
    snippet: l10n.getString("whatsnew-holiday-promo-2023-news-snippet"),
360
    content: (
361
      <WhatsNewContent
362
        description={l10n.getString(
363
          "whatsnew-holiday-promo-2023-news-content-description",
364
        )}
365
        heading={l10n.getString("whatsnew-holiday-promo-2023-news-heading")}
366
        image={HolidayPromo2023Hero}
367
        cta={
368
          <Link
369
            href={yearlyPlanRefWithCoupon}
370
            ref={getYearlyPlanBtnRef}
371
            onClick={() => {
372
              gaEvent({
×
373
                category: "Holiday Promo News CTA",
374
                action: "Engage",
375
                label: "holiday-promo-2023-news-cta",
376
              });
377
            }}
378
          >
379
            <span className={styles.cta}>
380
              {l10n.getString("whatsnew-holiday-promo-2023-cta")}
381
            </span>
382
          </Link>
383
        }
384
      />
385
    ),
386
    icon: HolidayPromo2023Icon,
387
    dismissal: useLocalDismissal(
388
      `whatsnew-holiday_promo_2023_${props.profile.id}`,
389
    ),
390
    announcementDate: {
391
      year: 2023,
392
      month: 11,
393
      day: 29,
394
    },
395
  };
396

397
  // Check if the holiday promotion entry should be added to the entries array
398
  if (
123✔
399
    isFlagActive(props.runtimeData, "holiday_promo_2023") &&
143✔
400
    !props.profile.has_premium &&
401
    isPeriodicalPremiumAvailableInCountry(props.runtimeData)
402
  ) {
403
    entries.push(holidayPromo2023);
10✔
404
  }
405

406
  const premiumEuExpansion: WhatsNewEntry = {
123✔
407
    title: l10n.getString("whatsnew-feature-premium-expansion-eu-heading"),
408
    snippet: l10n.getString("whatsnew-feature-premium-expansion-eu-snippet"),
409
    content: (
410
      <WhatsNewContent
411
        description={l10n.getString(
412
          "whatsnew-feature-premium-expansion-eu-description",
413
        )}
414
        heading={l10n.getString(
415
          "whatsnew-feature-premium-expansion-eu-heading",
416
        )}
417
        image={PremiumEuExpansionHero}
418
        cta={
419
          <Link href="/premium#pricing" legacyBehavior>
420
            <span className={styles.cta}>
421
              {l10n.getString("whatsnew-feature-premium-expansion-eu-cta")}
422
            </span>
423
          </Link>
424
        }
425
      />
426
    ),
427
    icon: PremiumEuExpansionIcon,
428
    dismissal: useLocalDismissal(
429
      `whatsnew-feature_premium-eu-expansion_2023_${props.profile.id}`,
430
    ),
431
    announcementDate: {
432
      year: 2023,
433
      month: 7,
434
      day: 26,
435
    },
436
  };
437

438
  if (
123!
439
    typeof props.runtimeData !== "undefined" &&
367✔
440
    !props.profile.has_premium &&
441
    [
442
      "bg",
443
      "cz",
444
      "cy",
445
      "dk",
446
      "ee",
447
      "gr",
448
      "hr",
449
      "hu",
450
      "lt",
451
      "lv",
452
      "lu",
453
      "mt",
454
      "pl",
455
      "pt",
456
      "ro",
457
      "si",
458
      "sk",
459
    ].includes(
460
      props.runtimeData.PERIODICAL_PREMIUM_PLANS.country_code.toLowerCase(),
461
    )
462
  ) {
463
    entries.push(premiumEuExpansion);
×
464
  }
465

466
  const trackerRemoval: WhatsNewEntry = {
123✔
467
    title: l10n.getString("whatsnew-feature-tracker-removal-heading"),
468
    snippet: l10n.getString("whatsnew-feature-tracker-removal-snippet"),
469
    content: (
470
      <WhatsNewContent
471
        description={l10n.getString(
472
          "whatsnew-feature-tracker-removal-description-2",
473
        )}
474
        heading={l10n.getString("whatsnew-feature-tracker-removal-heading")}
475
        image={TrackerRemovalHero}
476
      />
477
    ),
478
    icon: TrackerRemovalIcon,
479
    dismissal: useLocalDismissal(
480
      `whatsnew-feature_tracker-removal_${props.profile.id}`,
481
    ),
482
    announcementDate: {
483
      year: 2022,
484
      month: 8,
485
      day: 16,
486
    },
487
  };
488
  // TODO: MPP-4465: Retire tracker_removal flag as enabled
489
  // Only show its announcement if tracker removal is live:
490
  if (isFlagActive(props.runtimeData, "tracker_removal")) {
123✔
491
    entries.push(trackerRemoval);
14✔
492
  }
493

494
  const phoneAnnouncement: WhatsNewEntry = {
123✔
495
    title: l10n.getString("whatsnew-feature-phone-header"),
496
    snippet: l10n.getString("whatsnew-feature-phone-snippet"),
497
    content:
498
      props.runtimeData && isPhonesAvailableInCountry(props.runtimeData) ? (
369✔
499
        <WhatsNewContent
500
          description={l10n.getString("whatsnew-feature-phone-description")}
501
          heading={l10n.getString("whatsnew-feature-phone-header")}
502
          image={PhoneMaskingHero}
503
          videos={{
504
            // Unfortunately video files cannot currently be imported, so make
505
            // sure these files are present in /public. See
506
            // https://github.com/vercel/next.js/issues/35248
507
            "video/webm; codecs='vp9'":
508
              "/animations/whatsnew/phone-masking-hero.webm",
509
            "video/mp4": "/animations/whatsnew/phone-masking-hero.mp4",
510
          }}
511
          cta={
512
            <CtaLinkButton
513
              subscribed={props.profile.has_phone}
514
              label={l10n.getString("whatsnew-feature-phone-upgrade-cta")}
515
            />
516
          }
517
        />
518
      ) : null,
519

520
    icon: PhoneMaskingIcon,
521
    dismissal: useLocalDismissal(`whatsnew-feature_phone_${props.profile.id}`),
522
    announcementDate: {
523
      year: 2022,
524
      month: 10,
525
      day: 11,
526
    },
527
  };
528

529
  // Only show its announcement if phone masking is live:
530
  if (isPhonesAvailableInCountry(props.runtimeData)) {
123✔
531
    entries.push(phoneAnnouncement);
10✔
532
  }
533

534
  const vpnAndRelayAnnouncement: WhatsNewEntry = {
123✔
535
    title: l10n.getString("whatsnew-feature-bundle-header-2", {
536
      savings: "40%",
537
    }),
538
    snippet: l10n.getString("whatsnew-feature-bundle-snippet-2"),
539
    content:
540
      props.runtimeData && isBundleAvailableInCountry(props.runtimeData) ? (
369!
541
        <WhatsNewContent
542
          description={l10n.getString("whatsnew-feature-bundle-body-v2", {
543
            monthly_price: getBundlePrice(props.runtimeData, l10n),
544
            savings: "40%",
545
          })}
546
          heading={l10n.getString("whatsnew-feature-bundle-header-2", {
547
            savings: "40%",
548
          })}
549
          image={BundleHero}
550
          videos={{
551
            // Unfortunately video files cannot currently be imported, so make
552
            // sure these files are present in /public. See
553
            // https://github.com/vercel/next.js/issues/35248
554
            "video/webm; codecs='vp9'":
555
              "/animations/whatsnew/bundle-promo-hero.webm",
556
            "video/mp4": "/animations/whatsnew/bundle-promo-hero.mp4",
557
          }}
558
          cta={
559
            <CtaLinkButton
560
              // TODO: Add has_bundle to profile data => subscribed={props.profile.has_bundle}
561
              label={l10n.getString("whatsnew-feature-bundle-upgrade-cta")}
562
            />
563
          }
564
        />
565
      ) : null,
566

567
    icon: BundleIcon,
568
    dismissal: useLocalDismissal(`whatsnew-feature_phone_${props.profile.id}`),
569
    announcementDate: {
570
      year: 2022,
571
      month: 10,
572
      day: 11,
573
    },
574
  };
575

576
  // Only show its announcement if bundle is live:
577
  if (isBundleAvailableInCountry(props.runtimeData)) {
123!
578
    entries.push(vpnAndRelayAnnouncement);
×
579
  }
580

581
  const firefoxIntegrationAnnouncement: WhatsNewEntry = {
123✔
582
    title: l10n.getString("whatsnew-feature-firefox-integration-heading"),
583
    snippet: l10n.getString("whatsnew-feature-firefox-integration-snippet"),
584
    content: (
585
      <WhatsNewContent
586
        description={l10n.getString(
587
          "whatsnew-feature-firefox-integration-description",
588
        )}
589
        heading={l10n.getString("whatsnew-feature-firefox-integration-heading")}
590
        image={FirefoxIntegrationHero}
591
      />
592
    ),
593
    icon: FirefoxIntegrationIcon,
594
    dismissal: useLocalDismissal(
595
      `whatsnew-feature_firefox-integration_${props.profile.id}`,
596
    ),
597
    // Week after release of Firefox 111 (to ensure it was rolled out to everyone)
598
    announcementDate: {
599
      year: 2023,
600
      month: 3,
601
      day: 21,
602
    },
603
  };
604
  if (
123!
605
    isFlagActive(props.runtimeData, "firefox_integration") &&
133✔
606
    isUsingFirefox()
607
  ) {
608
    entries.push(firefoxIntegrationAnnouncement);
×
609
  }
610

611
  const mailingListAnnouncement: WhatsNewEntry = {
123✔
612
    title: l10n.getString("whatsnew-feature-mailing-list-heading"),
613
    snippet: l10n.getString("whatsnew-feature-mailing-list-snippet"),
614
    content: (
615
      <WhatsNewContent
616
        description={l10n.getString(
617
          "whatsnew-feature-mailing-list-description",
618
        )}
619
        heading={l10n.getString("whatsnew-feature-mailing-list-heading")}
620
        image={MailingListHero}
621
        cta={
622
          <a
623
            className={styles.cta}
624
            href="https://www.mozilla.org/newsletter/security-and-privacy/"
625
            target="_blank"
626
          >
627
            {l10n.getString("whatsnew-feature-mailing-list-cta")}
628
          </a>
629
        }
630
      />
631
    ),
632
    icon: MailingListIcon,
633
    dismissal: useLocalDismissal(
634
      `whatsnew-feature_mailing-list_${props.profile.id}`,
635
    ),
636
    announcementDate: {
637
      year: 2023,
638
      month: 6,
639
      day: 3,
640
    },
641
  };
642

643
  if (isFlagActive(props.runtimeData, "mailing_list_announcement")) {
123✔
644
    entries.push(mailingListAnnouncement);
10✔
645
  }
646

647
  const entriesNotInFuture = entries.filter((entry) => {
123✔
648
    const entryDate = new Date(
292✔
649
      Date.UTC(
650
        entry.announcementDate.year,
651
        entry.announcementDate.month - 1,
652
        entry.announcementDate.day,
653
      ),
654
    );
655
    // Filter out entries that are in the future:
656
    return entryDate.getTime() <= Date.now();
292✔
657
  });
658
  entriesNotInFuture.sort(entriesDescByDateSorter);
123✔
659

660
  const newEntries = entriesNotInFuture.filter((entry) => {
123✔
661
    const entryDate = new Date(
242✔
662
      Date.UTC(
663
        entry.announcementDate.year,
664
        entry.announcementDate.month - 1,
665
        entry.announcementDate.day,
666
      ),
667
    );
668
    const ageInMilliSeconds = Date.now() - entryDate.getTime();
242✔
669
    // Automatically move entries to the archive after 30 days:
670
    const isExpired = ageInMilliSeconds > 30 * 24 * 60 * 60 * 1000;
242✔
671
    return !entry.dismissal.isDismissed && !isExpired;
242✔
672
  });
673

674
  const { triggerProps, overlayProps } = useOverlayTrigger(
123✔
675
    { type: "dialog" },
676
    triggerState,
677
    triggerRef,
678
  );
679

680
  const positionProps = useOverlayPosition({
123✔
681
    targetRef: triggerRef,
682
    overlayRef: overlayRef,
683
    placement: "bottom end",
684
    offset: 10,
685
    isOpen: triggerState.isOpen,
686
  }).overlayProps;
687
  const overlayBugWorkaround = useOverlayBugWorkaround(triggerState);
123✔
688

689
  const { buttonProps } = useButton(triggerProps, triggerRef);
123✔
690

691
  if (entriesNotInFuture.length === 0) {
123!
692
    return null;
×
693
  }
694

695
  const pill =
696
    newEntries.length > 0 ? (
123✔
697
      <i
698
        aria-label={l10n.getString("whatsnew-counter-label", {
699
          count: newEntries.length,
700
        })}
701
        className={styles.pill}
702
        data-testid="whatsnew-pill"
703
      >
704
        {newEntries.length}
705
      </i>
706
    ) : null;
707

708
  return (
709
    <>
710
      {overlayBugWorkaround}
711
      <button
712
        {...buttonProps}
713
        ref={triggerRef}
714
        data-testid="whatsnew-trigger"
715
        className={`${styles.trigger} ${
716
          triggerState.isOpen ? styles["is-open"] : ""
123✔
717
        } ${props.style}`}
718
      >
719
        <GiftIcon
720
          className={styles["trigger-icon"]}
721
          alt={l10n.getString("whatsnew-trigger-label")}
722
        />
723
        <span className={styles["trigger-label"]}>
724
          {l10n.getString("whatsnew-trigger-label")}
725
        </span>
726
        {pill}
727
      </button>
728
      {triggerState.isOpen && (
123✔
729
        <OverlayContainer>
730
          <WhatsNewPopover
731
            {...overlayProps}
732
            {...positionProps}
733
            ref={overlayRef}
734
            title={l10n.getString("whatsnew-trigger-label")}
735
            isOpen={triggerState.isOpen}
736
            onClose={() => triggerState.close()}
×
737
          >
738
            <WhatsNewDashboard
739
              new={newEntries}
740
              archive={entriesNotInFuture}
741
              onClose={() => triggerState.close()}
×
742
            />
743
          </WhatsNewPopover>
744
        </OverlayContainer>
745
      )}
746
    </>
747
  );
748
};
749

750
type PopoverProps = {
751
  title: string;
752
  children: ReactNode;
753
  isOpen: boolean;
754
  onClose: () => void;
755
} & HTMLAttributes<HTMLDivElement>;
756
const WhatsNewPopover = forwardRef<HTMLDivElement, PopoverProps>(
9✔
757
  ({ title, children, isOpen, onClose, ...otherProps }, ref) => {
758
    const { overlayProps } = useOverlay(
3✔
759
      {
760
        onClose: onClose,
761
        isOpen: isOpen,
762
        isDismissable: true,
763
      },
764
      ref as RefObject<HTMLDivElement>,
765
    );
766

767
    const { modalProps } = useModal();
3✔
768

769
    const { dialogProps, titleProps } = useDialog(
3✔
770
      {},
771
      ref as RefObject<HTMLDivElement>,
772
    );
773

774
    const mergedOverlayProps = mergeProps(
3✔
775
      overlayProps,
776
      dialogProps,
777
      otherProps,
778
      modalProps,
779
    );
780

781
    return (
782
      <FocusScope restoreFocus contain autoFocus>
783
        <div
784
          {...mergedOverlayProps}
785
          ref={ref}
786
          className={styles["popover-wrapper"]}
787
        >
788
          <VisuallyHidden>
789
            <h2 {...titleProps}>{title}</h2>
790
          </VisuallyHidden>
791
          {children}
792
          <DismissButton onDismiss={onClose} />
793
        </div>
794
      </FocusScope>
795
    );
796
  },
797
);
798
WhatsNewPopover.displayName = "WhatsNewPopover";
9✔
799

800
const entriesDescByDateSorter: Parameters<Array<WhatsNewEntry>["sort"]>[0] = (
9✔
801
  entryA,
802
  entryB,
803
) => {
804
  const dateANr =
805
    entryA.announcementDate.year +
123✔
806
    entryA.announcementDate.month / 100 +
807
    entryA.announcementDate.day / 10000;
808
  const dateBNr =
809
    entryB.announcementDate.year +
123✔
810
    entryB.announcementDate.month / 100 +
811
    entryB.announcementDate.day / 10000;
812

813
  return dateBNr - dateANr;
123✔
814
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc