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

mozilla / fx-private-relay / a2bc0383-1205-4ebd-979f-e7ee6dba9a0d

18 Dec 2023 05:15PM UTC coverage: 73.514% (-0.7%) from 74.258%
a2bc0383-1205-4ebd-979f-e7ee6dba9a0d

push

circleci

jwhitlock
Add provider_id="" to SocialApp init

1962 of 2913 branches covered (0.0%)

Branch coverage included in aggregate %.

6273 of 8289 relevant lines covered (75.68%)

19.91 hits per line

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

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

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

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

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

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

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

122
export const WhatsNewMenu = (props: Props) => {
77✔
123
  const l10n = useL10n();
98✔
124

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

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

139
  const entries: WhatsNewEntry[] = [
98✔
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 = {
98✔
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) {
98!
202
    entries.push(forwardSomeEntry);
×
203
  }
204

205
  const signBackInEntry: WhatsNewEntry = {
98✔
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()) {
98!
228
    entries.push(signBackInEntry);
×
229
  }
230

231
  const aliasToMask: WhatsNewEntry = {
98✔
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 (
98✔
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);
98✔
275
  }
276

277
  const premiumInSweden: WhatsNewEntry = {
98✔
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 (
98!
302
    props.runtimeData?.PERIODICAL_PREMIUM_PLANS.country_code.toLowerCase() ===
98!
303
      "se" &&
304
    !props.profile.has_premium
305
  ) {
306
    entries.push(premiumInSweden);
×
307
  }
308

309
  const premiumInFinland: WhatsNewEntry = {
98✔
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 (
98!
334
    props.runtimeData?.PERIODICAL_PREMIUM_PLANS.country_code.toLowerCase() ===
98!
335
      "fi" &&
336
    !props.profile.has_premium
337
  ) {
338
    entries.push(premiumInFinland);
×
339
  }
340

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

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

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

394
  // Check if the holiday promotion entry should be added to the entries array
395
  if (
98!
396
    isFlagActive(props.runtimeData, "holiday_promo_2023") &&
98!
397
    !props.profile.has_premium &&
398
    isPeriodicalPremiumAvailableInCountry(props.runtimeData)
399
  ) {
400
    entries.push(holidayPromo2023);
×
401
  }
402

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

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

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

490
  const endDateFormatter = new Intl.DateTimeFormat(getLocale(l10n), {
98✔
491
    dateStyle: "long",
492
  });
493
  // Introductory pricing ended 2022-09-27T09:00:00.000-07:00:
494
  const introPricingOfferEndDate = new Date(1664294400000);
98✔
495

496
  const introPricingCountdown: WhatsNewEntry = {
98✔
497
    title: l10n.getString("whatsnew-feature-offer-countdown-heading"),
498
    snippet: l10n.getString("whatsnew-feature-offer-countdown-snippet", {
499
      end_date: endDateFormatter.format(introPricingOfferEndDate),
500
    }),
501
    content: (
502
      <WhatsNewComponentContent
503
        description={l10n.getString(
504
          "whatsnew-feature-offer-countdown-description",
505
          { end_date: endDateFormatter.format(introPricingOfferEndDate) },
506
        )}
507
        heading={l10n.getString("whatsnew-feature-offer-countdown-heading")}
508
        hero={
509
          <div className={styles["countdown-timer"]}>
510
            <CountdownTimer remainingTimeInMs={0} />
511
          </div>
512
        }
513
      />
514
    ),
515
    icon: OfferCountdownIcon,
516
    dismissal: useLocalDismissal(
517
      `whatsnew-feature_offer-countdown_${props.profile.id}`,
518
    ),
519
    announcementDate: {
520
      year: 2022,
521
      month: 9,
522
      day: 13,
523
    },
524
  };
525
  // Make sure to move the end-of-intro-pricing news entry is in the History
526
  // tab now that the countdown has finished:
527
  introPricingCountdown.dismissal.isDismissed = true;
98✔
528
  if (
98✔
529
    // If the user does not have Premium yet,
530
    !props.profile.has_premium &&
196✔
531
    // …but is able to purchase Premium
532
    isPeriodicalPremiumAvailableInCountry(props.runtimeData)
533
  ) {
534
    entries.push(introPricingCountdown);
97✔
535
  }
536

537
  const phoneAnnouncement: WhatsNewEntry = {
98✔
538
    title: l10n.getString("whatsnew-feature-phone-header"),
539
    snippet: l10n.getString("whatsnew-feature-phone-snippet"),
540
    content:
541
      props.runtimeData && isPhonesAvailableInCountry(props.runtimeData) ? (
294!
542
        <WhatsNewContent
543
          description={l10n.getString("whatsnew-feature-phone-description")}
544
          heading={l10n.getString("whatsnew-feature-phone-header")}
545
          image={PhoneMaskingHero}
546
          videos={{
547
            // Unfortunately video files cannot currently be imported, so make
548
            // sure these files are present in /public. See
549
            // https://github.com/vercel/next.js/issues/35248
550
            "video/webm; codecs='vp9'":
551
              "/animations/whatsnew/phone-masking-hero.webm",
552
            "video/mp4": "/animations/whatsnew/phone-masking-hero.mp4",
553
          }}
554
          cta={
555
            <CtaLinkButton
556
              subscribed={props.profile.has_phone}
557
              label={l10n.getString("whatsnew-feature-phone-upgrade-cta")}
558
            />
559
          }
560
        />
561
      ) : null,
562

563
    icon: PhoneMaskingIcon,
564
    dismissal: useLocalDismissal(`whatsnew-feature_phone_${props.profile.id}`),
565
    announcementDate: {
566
      year: 2022,
567
      month: 10,
568
      day: 11,
569
    },
570
  };
571

572
  // Only show its announcement if phone masking is live:
573
  if (isPhonesAvailableInCountry(props.runtimeData)) {
98!
574
    entries.push(phoneAnnouncement);
×
575
  }
576

577
  const vpnAndRelayAnnouncement: WhatsNewEntry = {
98✔
578
    title: l10n.getString("whatsnew-feature-bundle-header-2", {
579
      savings: "40%",
580
    }),
581
    snippet: l10n.getString("whatsnew-feature-bundle-snippet-2"),
582
    content:
583
      props.runtimeData && isBundleAvailableInCountry(props.runtimeData) ? (
294!
584
        <WhatsNewContent
585
          description={l10n.getString("whatsnew-feature-bundle-body-v2", {
586
            monthly_price: getBundlePrice(props.runtimeData, l10n),
587
            savings: "40%",
588
          })}
589
          heading={l10n.getString("whatsnew-feature-bundle-header-2", {
590
            savings: "40%",
591
          })}
592
          image={BundleHero}
593
          videos={{
594
            // Unfortunately video files cannot currently be imported, so make
595
            // sure these files are present in /public. See
596
            // https://github.com/vercel/next.js/issues/35248
597
            "video/webm; codecs='vp9'":
598
              "/animations/whatsnew/bundle-promo-hero.webm",
599
            "video/mp4": "/animations/whatsnew/bundle-promo-hero.mp4",
600
          }}
601
          cta={
602
            <CtaLinkButton
603
              // TODO: Add has_bundle to profile data => subscribed={props.profile.has_bundle}
604
              label={l10n.getString("whatsnew-feature-bundle-upgrade-cta")}
605
            />
606
          }
607
        />
608
      ) : null,
609

610
    icon: BundleIcon,
611
    dismissal: useLocalDismissal(`whatsnew-feature_phone_${props.profile.id}`),
612
    announcementDate: {
613
      year: 2022,
614
      month: 10,
615
      day: 11,
616
    },
617
  };
618

619
  // Only show its announcement if bundle is live:
620
  if (isBundleAvailableInCountry(props.runtimeData)) {
98!
621
    entries.push(vpnAndRelayAnnouncement);
×
622
  }
623

624
  const firefoxIntegrationAnnouncement: WhatsNewEntry = {
98✔
625
    title: l10n.getString("whatsnew-feature-firefox-integration-heading"),
626
    snippet: l10n.getString("whatsnew-feature-firefox-integration-snippet"),
627
    content: (
628
      <WhatsNewContent
629
        description={l10n.getString(
630
          "whatsnew-feature-firefox-integration-description",
631
        )}
632
        heading={l10n.getString("whatsnew-feature-firefox-integration-heading")}
633
        image={FirefoxIntegrationHero}
634
      />
635
    ),
636
    icon: FirefoxIntegrationIcon,
637
    dismissal: useLocalDismissal(
638
      `whatsnew-feature_firefox-integration_${props.profile.id}`,
639
    ),
640
    // Week after release of Firefox 111 (to ensure it was rolled out to everyone)
641
    announcementDate: {
642
      year: 2023,
643
      month: 3,
644
      day: 21,
645
    },
646
  };
647
  if (
98!
648
    isFlagActive(props.runtimeData, "firefox_integration") &&
98!
649
    isUsingFirefox()
650
  ) {
651
    entries.push(firefoxIntegrationAnnouncement);
×
652
  }
653

654
  const mailingListAnnouncement: WhatsNewEntry = {
98✔
655
    title: l10n.getString("whatsnew-feature-mailing-list-heading"),
656
    snippet: l10n.getString("whatsnew-feature-mailing-list-snippet"),
657
    content: (
658
      <WhatsNewContent
659
        description={l10n.getString(
660
          "whatsnew-feature-mailing-list-description",
661
        )}
662
        heading={l10n.getString("whatsnew-feature-mailing-list-heading")}
663
        image={MailingListHero}
664
        cta={
665
          <a
666
            className={styles.cta}
667
            href="https://www.mozilla.org/newsletter/security-and-privacy/"
668
            target="_blank"
669
          >
670
            {l10n.getString("whatsnew-feature-mailing-list-cta")}
671
          </a>
672
        }
673
      />
674
    ),
675
    icon: MailingListIcon,
676
    dismissal: useLocalDismissal(
677
      `whatsnew-feature_mailing-list_${props.profile.id}`,
678
    ),
679
    announcementDate: {
680
      year: 2023,
681
      month: 6,
682
      day: 3,
683
    },
684
  };
685

686
  if (isFlagActive(props.runtimeData, "mailing_list_announcement")) {
98!
687
    entries.push(mailingListAnnouncement);
×
688
  }
689

690
  const entriesNotInFuture = entries.filter((entry) => {
98✔
691
    const entryDate = new Date(
293✔
692
      Date.UTC(
693
        entry.announcementDate.year,
694
        entry.announcementDate.month - 1,
695
        entry.announcementDate.day,
696
      ),
697
    );
698
    // Filter out entries that are in the future:
699
    return entryDate.getTime() <= Date.now();
293✔
700
  });
701
  entriesNotInFuture.sort(entriesDescByDateSorter);
98✔
702

703
  const newEntries = entriesNotInFuture.filter((entry) => {
98✔
704
    const entryDate = new Date(
293✔
705
      Date.UTC(
706
        entry.announcementDate.year,
707
        entry.announcementDate.month - 1,
708
        entry.announcementDate.day,
709
      ),
710
    );
711
    const ageInMilliSeconds = Date.now() - entryDate.getTime();
293✔
712
    // Automatically move entries to the archive after 30 days:
713
    const isExpired = ageInMilliSeconds > 30 * 24 * 60 * 60 * 1000;
293✔
714
    return !entry.dismissal.isDismissed && !isExpired;
293✔
715
  });
716

717
  const { triggerProps, overlayProps } = useOverlayTrigger(
98✔
718
    { type: "dialog" },
719
    triggerState,
720
    triggerRef,
721
  );
722

723
  const positionProps = useOverlayPosition({
98✔
724
    targetRef: triggerRef,
725
    overlayRef: overlayRef,
726
    placement: "bottom end",
727
    offset: 10,
728
    isOpen: triggerState.isOpen,
729
  }).overlayProps;
730
  const overlayBugWorkaround = useOverlayBugWorkaround(triggerState);
98✔
731

732
  const { buttonProps } = useButton(triggerProps, triggerRef);
98✔
733

734
  if (entriesNotInFuture.length === 0) {
98!
735
    return null;
×
736
  }
737

738
  const pill =
739
    newEntries.length > 0 ? (
98!
740
      <i
741
        aria-label={l10n.getString("whatsnew-counter-label", {
742
          count: newEntries.length,
743
        })}
744
        className={styles.pill}
745
      >
746
        {newEntries.length}
747
      </i>
748
    ) : null;
749

750
  return (
751
    <>
752
      {overlayBugWorkaround}
753
      <button
754
        {...buttonProps}
755
        ref={triggerRef}
756
        className={`${styles.trigger} ${
757
          triggerState.isOpen ? styles["is-open"] : ""
98!
758
        } ${props.style}`}
759
      >
760
        <GiftIcon
761
          className={styles["trigger-icon"]}
762
          alt={l10n.getString("whatsnew-trigger-label")}
763
        />
764
        <span className={styles["trigger-label"]}>
765
          {l10n.getString("whatsnew-trigger-label")}
766
        </span>
767
        {pill}
768
      </button>
769
      {triggerState.isOpen && (
98✔
770
        <OverlayContainer>
771
          <WhatsNewPopover
772
            {...overlayProps}
773
            {...positionProps}
774
            ref={overlayRef}
775
            title={l10n.getString("whatsnew-trigger-label")}
776
            isOpen={triggerState.isOpen}
777
            onClose={() => triggerState.close()}
×
778
          >
779
            <WhatsNewDashboard
780
              new={newEntries}
781
              archive={entriesNotInFuture}
782
              onClose={() => triggerState.close()}
×
783
            />
784
          </WhatsNewPopover>
785
        </OverlayContainer>
786
      )}
787
    </>
788
  );
789
};
790

791
type PopoverProps = {
792
  title: string;
793
  children: ReactNode;
794
  isOpen: boolean;
795
  onClose: () => void;
796
} & HTMLAttributes<HTMLDivElement>;
797
const WhatsNewPopover = forwardRef<HTMLDivElement, PopoverProps>(
8✔
798
  ({ title, children, isOpen, onClose, ...otherProps }, ref) => {
799
    const { overlayProps } = useOverlay(
×
800
      {
801
        onClose: onClose,
802
        isOpen: isOpen,
803
        isDismissable: true,
804
      },
805
      ref as RefObject<HTMLDivElement>,
806
    );
807

808
    const { modalProps } = useModal();
×
809

810
    const { dialogProps, titleProps } = useDialog(
×
811
      {},
812
      ref as RefObject<HTMLDivElement>,
813
    );
814

815
    const mergedOverlayProps = mergeProps(
×
816
      overlayProps,
817
      dialogProps,
818
      otherProps,
819
      modalProps,
820
    );
821

822
    return (
823
      <FocusScope restoreFocus contain autoFocus>
824
        <div
825
          {...mergedOverlayProps}
826
          ref={ref}
827
          className={styles["popover-wrapper"]}
828
        >
829
          <VisuallyHidden>
830
            <h2 {...titleProps}>{title}</h2>
831
          </VisuallyHidden>
832
          {children}
833
          <DismissButton onDismiss={onClose} />
834
        </div>
835
      </FocusScope>
836
    );
837
  },
838
);
839
WhatsNewPopover.displayName = "WhatsNewPopover";
8✔
840

841
const entriesDescByDateSorter: Parameters<Array<WhatsNewEntry>["sort"]>[0] = (
8✔
842
  entryA,
843
  entryB,
844
) => {
845
  const dateANr =
846
    entryA.announcementDate.year +
195✔
847
    entryA.announcementDate.month / 100 +
848
    entryA.announcementDate.day / 10000;
849
  const dateBNr =
850
    entryB.announcementDate.year +
195✔
851
    entryB.announcementDate.month / 100 +
852
    entryB.announcementDate.day / 10000;
853

854
  return dateBNr - dateANr;
195✔
855
};
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