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

mozilla / fx-private-relay / a1cf66f5-3425-4bcb-916b-c0bba1ffc71f

19 Oct 2023 12:58PM CUT coverage: 74.678% (-0.007%) from 74.685%
a1cf66f5-3425-4bcb-916b-c0bba1ffc71f

push

circleci

Vinnl
Restore overlay bug workaround for news menu

This partially reverts commit 44d2e15cf.

Unfortunately, our entire website was inaccessible to screen readers,
because the <OverlayProvider> set aria-hidden by default, and git
bisect turned up the above commit as the culprit.

1927 of 2792 branches covered (0.0%)

Branch coverage included in aggregate %.

10 of 10 new or added lines in 2 files covered. (100.0%)

6074 of 7922 relevant lines covered (76.67%)

18.62 hits per line

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

74.7
/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 BundleHero from "./images/bundle-promo-hero.svg";
8✔
43
import BundleIcon from "./images/bundle-promo-icon.svg";
8✔
44
import OfferCountdownIcon from "./images/offer-countdown-icon.svg";
8✔
45
import FirefoxIntegrationHero from "./images/firefox-integration-hero.svg";
8✔
46
import FirefoxIntegrationIcon from "./images/firefox-integration-icon.svg";
8✔
47
import MailingListHero from "./images/mailing-list-hero.svg";
8✔
48
import MailingListIcon from "./images/mailing-list-icon.svg";
8✔
49
import { WhatsNewComponentContent, WhatsNewContent } from "./WhatsNewContent";
8✔
50
import {
51
  DismissalData,
52
  useLocalDismissal,
53
} from "../../../../hooks/localDismissal";
8✔
54
import { ProfileData } from "../../../../hooks/api/profile";
55
import { WhatsNewDashboard } from "./WhatsNewDashboard";
8✔
56
import { useAddonData } from "../../../../hooks/addon";
8✔
57
import { isUsingFirefox } from "../../../../functions/userAgent";
8✔
58
import { getLocale } from "../../../../functions/getLocale";
8✔
59
import { RuntimeData } from "../../../../hooks/api/runtimeData";
60
import { isFlagActive } from "../../../../functions/waffle";
8✔
61
import {
62
  getBundlePrice,
63
  isBundleAvailableInCountry,
64
  isPeriodicalPremiumAvailableInCountry,
65
  isPhonesAvailableInCountry,
66
} from "../../../../functions/getPlan";
8✔
67
import { CountdownTimer } from "../../../CountdownTimer";
8✔
68
import Link from "next/link";
8✔
69
import { GiftIcon } from "../../../Icons";
8✔
70
import { useL10n } from "../../../../hooks/l10n";
8✔
71
import { VisuallyHidden } from "../../../VisuallyHidden";
8✔
72
import { useOverlayBugWorkaround } from "../../../../hooks/overlayBugWorkaround";
8✔
73

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

92
export type Props = {
93
  profile: ProfileData;
94
  style: string;
95
  runtimeData?: RuntimeData;
96
};
97

98
type CtaProps = {
99
  link?: string;
100
  label: string;
101
  subscribed?: boolean;
102
};
103

104
const CtaLinkButton = (props: CtaProps) => {
8✔
105
  const hasSubscription = props.subscribed;
×
106

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

118
export const WhatsNewMenu = (props: Props) => {
70✔
119
  const l10n = useL10n();
89✔
120

121
  const triggerState = useOverlayTriggerState({
89✔
122
    onOpenChange(isOpen) {
123
      gaEvent({
×
124
        category: "News",
125
        action: isOpen ? "Open" : "Close",
×
126
        label: "header-nav",
127
      });
128
    },
129
  });
130

131
  const triggerRef = useRef<HTMLButtonElement>(null);
89✔
132
  const overlayRef = useRef<HTMLDivElement>(null);
89✔
133
  const addonData = useAddonData();
89✔
134

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

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

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

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

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

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

337
  const premiumEuExpansion: WhatsNewEntry = {
89✔
338
    title: l10n.getString("whatsnew-feature-premium-expansion-eu-heading"),
339
    snippet: l10n.getString("whatsnew-feature-premium-expansion-eu-snippet"),
340
    content: (
341
      <WhatsNewContent
342
        description={l10n.getString(
343
          "whatsnew-feature-premium-expansion-eu-description",
344
        )}
345
        heading={l10n.getString(
346
          "whatsnew-feature-premium-expansion-eu-heading",
347
        )}
348
        image={PremiumEuExpansionHero}
349
        cta={
350
          <Link href="/premium#pricing" legacyBehavior>
351
            <span className={styles.cta}>
352
              {l10n.getString("whatsnew-feature-premium-expansion-eu-cta")}
353
            </span>
354
          </Link>
355
        }
356
      />
357
    ),
358
    icon: PremiumEuExpansionIcon,
359
    dismissal: useLocalDismissal(
360
      `whatsnew-feature_premium-eu-expansion_2023_${props.profile.id}`,
361
    ),
362
    announcementDate: {
363
      year: 2023,
364
      month: 7,
365
      day: 26,
366
    },
367
  };
368

369
  if (
89!
370
    typeof props.runtimeData !== "undefined" &&
267✔
371
    !props.profile.has_premium &&
372
    [
373
      "bg",
374
      "cz",
375
      "cy",
376
      "dk",
377
      "ee",
378
      "gr",
379
      "hr",
380
      "hu",
381
      "lt",
382
      "lv",
383
      "lu",
384
      "mt",
385
      "pl",
386
      "pt",
387
      "ro",
388
      "si",
389
      "sk",
390
    ].includes(
391
      props.runtimeData.PERIODICAL_PREMIUM_PLANS.country_code.toLowerCase(),
392
    )
393
  ) {
394
    entries.push(premiumEuExpansion);
×
395
  }
396

397
  const trackerRemoval: WhatsNewEntry = {
89✔
398
    title: l10n.getString("whatsnew-feature-tracker-removal-heading"),
399
    snippet: l10n.getString("whatsnew-feature-tracker-removal-snippet"),
400
    content: (
401
      <WhatsNewContent
402
        description={l10n.getString(
403
          "whatsnew-feature-tracker-removal-description-2",
404
        )}
405
        heading={l10n.getString("whatsnew-feature-tracker-removal-heading")}
406
        image={TrackerRemovalHero}
407
      />
408
    ),
409
    icon: TrackerRemovalIcon,
410
    dismissal: useLocalDismissal(
411
      `whatsnew-feature_tracker-removal_${props.profile.id}`,
412
    ),
413
    announcementDate: {
414
      year: 2022,
415
      month: 8,
416
      day: 16,
417
    },
418
  };
419
  // Only show its announcement if tracker removal is live:
420
  if (isFlagActive(props.runtimeData, "tracker_removal")) {
89!
421
    entries.push(trackerRemoval);
×
422
  }
423

424
  const endDateFormatter = new Intl.DateTimeFormat(getLocale(l10n), {
89✔
425
    dateStyle: "long",
426
  });
427
  // Introductory pricing ended 2022-09-27T09:00:00.000-07:00:
428
  const introPricingOfferEndDate = new Date(1664294400000);
89✔
429

430
  const introPricingCountdown: WhatsNewEntry = {
89✔
431
    title: l10n.getString("whatsnew-feature-offer-countdown-heading"),
432
    snippet: l10n.getString("whatsnew-feature-offer-countdown-snippet", {
433
      end_date: endDateFormatter.format(introPricingOfferEndDate),
434
    }),
435
    content: (
436
      <WhatsNewComponentContent
437
        description={l10n.getString(
438
          "whatsnew-feature-offer-countdown-description",
439
          { end_date: endDateFormatter.format(introPricingOfferEndDate) },
440
        )}
441
        heading={l10n.getString("whatsnew-feature-offer-countdown-heading")}
442
        hero={
443
          <div className={styles["countdown-timer"]}>
444
            <CountdownTimer remainingTimeInMs={0} />
445
          </div>
446
        }
447
      />
448
    ),
449
    icon: OfferCountdownIcon,
450
    dismissal: useLocalDismissal(
451
      `whatsnew-feature_offer-countdown_${props.profile.id}`,
452
    ),
453
    announcementDate: {
454
      year: 2022,
455
      month: 9,
456
      day: 13,
457
    },
458
  };
459
  // Make sure to move the end-of-intro-pricing news entry is in the History
460
  // tab now that the countdown has finished:
461
  introPricingCountdown.dismissal.isDismissed = true;
89✔
462
  if (
89✔
463
    // If the user does not have Premium yet,
464
    !props.profile.has_premium &&
178✔
465
    // …but is able to purchase Premium
466
    isPeriodicalPremiumAvailableInCountry(props.runtimeData)
467
  ) {
468
    entries.push(introPricingCountdown);
88✔
469
  }
470

471
  const phoneAnnouncement: WhatsNewEntry = {
89✔
472
    title: l10n.getString("whatsnew-feature-phone-header"),
473
    snippet: l10n.getString("whatsnew-feature-phone-snippet"),
474
    content:
475
      props.runtimeData && isPhonesAvailableInCountry(props.runtimeData) ? (
267!
476
        <WhatsNewContent
477
          description={l10n.getString("whatsnew-feature-phone-description")}
478
          heading={l10n.getString("whatsnew-feature-phone-header")}
479
          image={PhoneMaskingHero}
480
          videos={{
481
            // Unfortunately video files cannot currently be imported, so make
482
            // sure these files are present in /public. See
483
            // https://github.com/vercel/next.js/issues/35248
484
            "video/webm; codecs='vp9'":
485
              "/animations/whatsnew/phone-masking-hero.webm",
486
            "video/mp4": "/animations/whatsnew/phone-masking-hero.mp4",
487
          }}
488
          cta={
489
            <CtaLinkButton
490
              subscribed={props.profile.has_phone}
491
              label={l10n.getString("whatsnew-feature-phone-upgrade-cta")}
492
            />
493
          }
494
        />
495
      ) : null,
496

497
    icon: PhoneMaskingIcon,
498
    dismissal: useLocalDismissal(`whatsnew-feature_phone_${props.profile.id}`),
499
    announcementDate: {
500
      year: 2022,
501
      month: 10,
502
      day: 11,
503
    },
504
  };
505

506
  // Only show its announcement if phone masking is live:
507
  if (isPhonesAvailableInCountry(props.runtimeData)) {
89!
508
    entries.push(phoneAnnouncement);
×
509
  }
510

511
  const vpnAndRelayAnnouncement: WhatsNewEntry = {
89✔
512
    title: l10n.getString("whatsnew-feature-bundle-header-2", {
513
      savings: "40%",
514
    }),
515
    snippet: l10n.getString("whatsnew-feature-bundle-snippet-2"),
516
    content:
517
      props.runtimeData && isBundleAvailableInCountry(props.runtimeData) ? (
267!
518
        <WhatsNewContent
519
          description={l10n.getString("whatsnew-feature-bundle-body-v2", {
520
            monthly_price: getBundlePrice(props.runtimeData, l10n),
521
            savings: "40%",
522
          })}
523
          heading={l10n.getString("whatsnew-feature-bundle-header-2", {
524
            savings: "40%",
525
          })}
526
          image={BundleHero}
527
          videos={{
528
            // Unfortunately video files cannot currently be imported, so make
529
            // sure these files are present in /public. See
530
            // https://github.com/vercel/next.js/issues/35248
531
            "video/webm; codecs='vp9'":
532
              "/animations/whatsnew/bundle-promo-hero.webm",
533
            "video/mp4": "/animations/whatsnew/bundle-promo-hero.mp4",
534
          }}
535
          cta={
536
            <CtaLinkButton
537
              // TODO: Add has_bundle to profile data => subscribed={props.profile.has_bundle}
538
              label={l10n.getString("whatsnew-feature-bundle-upgrade-cta")}
539
            />
540
          }
541
        />
542
      ) : null,
543

544
    icon: BundleIcon,
545
    dismissal: useLocalDismissal(`whatsnew-feature_phone_${props.profile.id}`),
546
    announcementDate: {
547
      year: 2022,
548
      month: 10,
549
      day: 11,
550
    },
551
  };
552

553
  // Only show its announcement if bundle is live:
554
  if (isBundleAvailableInCountry(props.runtimeData)) {
89!
555
    entries.push(vpnAndRelayAnnouncement);
×
556
  }
557

558
  const firefoxIntegrationAnnouncement: WhatsNewEntry = {
89✔
559
    title: l10n.getString("whatsnew-feature-firefox-integration-heading"),
560
    snippet: l10n.getString("whatsnew-feature-firefox-integration-snippet"),
561
    content: (
562
      <WhatsNewContent
563
        description={l10n.getString(
564
          "whatsnew-feature-firefox-integration-description",
565
        )}
566
        heading={l10n.getString("whatsnew-feature-firefox-integration-heading")}
567
        image={FirefoxIntegrationHero}
568
      />
569
    ),
570
    icon: FirefoxIntegrationIcon,
571
    dismissal: useLocalDismissal(
572
      `whatsnew-feature_firefox-integration_${props.profile.id}`,
573
    ),
574
    // Week after release of Firefox 111 (to ensure it was rolled out to everyone)
575
    announcementDate: {
576
      year: 2023,
577
      month: 3,
578
      day: 21,
579
    },
580
  };
581
  if (
89!
582
    isFlagActive(props.runtimeData, "firefox_integration") &&
89!
583
    isUsingFirefox()
584
  ) {
585
    entries.push(firefoxIntegrationAnnouncement);
×
586
  }
587

588
  const mailingListAnnouncement: WhatsNewEntry = {
89✔
589
    title: l10n.getString("whatsnew-feature-mailing-list-heading"),
590
    snippet: l10n.getString("whatsnew-feature-mailing-list-snippet"),
591
    content: (
592
      <WhatsNewContent
593
        description={l10n.getString(
594
          "whatsnew-feature-mailing-list-description",
595
        )}
596
        heading={l10n.getString("whatsnew-feature-mailing-list-heading")}
597
        image={MailingListHero}
598
        cta={
599
          <a
600
            className={styles.cta}
601
            href="https://www.mozilla.org/newsletter/security-and-privacy/"
602
            target="_blank"
603
          >
604
            {l10n.getString("whatsnew-feature-mailing-list-cta")}
605
          </a>
606
        }
607
      />
608
    ),
609
    icon: MailingListIcon,
610
    dismissal: useLocalDismissal(
611
      `whatsnew-feature_mailing-list_${props.profile.id}`,
612
    ),
613
    announcementDate: {
614
      year: 2023,
615
      month: 6,
616
      day: 3,
617
    },
618
  };
619

620
  if (isFlagActive(props.runtimeData, "mailing_list_announcement")) {
89!
621
    entries.push(mailingListAnnouncement);
×
622
  }
623

624
  const entriesNotInFuture = entries.filter((entry) => {
89✔
625
    const entryDate = new Date(
266✔
626
      Date.UTC(
627
        entry.announcementDate.year,
628
        entry.announcementDate.month - 1,
629
        entry.announcementDate.day,
630
      ),
631
    );
632
    // Filter out entries that are in the future:
633
    return entryDate.getTime() <= Date.now();
266✔
634
  });
635
  entriesNotInFuture.sort(entriesDescByDateSorter);
89✔
636

637
  const newEntries = entriesNotInFuture.filter((entry) => {
89✔
638
    const entryDate = new Date(
266✔
639
      Date.UTC(
640
        entry.announcementDate.year,
641
        entry.announcementDate.month - 1,
642
        entry.announcementDate.day,
643
      ),
644
    );
645
    const ageInMilliSeconds = Date.now() - entryDate.getTime();
266✔
646
    // Automatically move entries to the archive after 30 days:
647
    const isExpired = ageInMilliSeconds > 30 * 24 * 60 * 60 * 1000;
266✔
648
    return !entry.dismissal.isDismissed && !isExpired;
266✔
649
  });
650

651
  const { triggerProps, overlayProps } = useOverlayTrigger(
89✔
652
    { type: "dialog" },
653
    triggerState,
654
    triggerRef,
655
  );
656

657
  const positionProps = useOverlayPosition({
89✔
658
    targetRef: triggerRef,
659
    overlayRef: overlayRef,
660
    placement: "bottom end",
661
    offset: 10,
662
    isOpen: triggerState.isOpen,
663
  }).overlayProps;
664
  const overlayBugWorkaround = useOverlayBugWorkaround(triggerState);
89✔
665

666
  const { buttonProps } = useButton(triggerProps, triggerRef);
89✔
667

668
  if (entriesNotInFuture.length === 0) {
89!
669
    return null;
×
670
  }
671

672
  const pill =
673
    newEntries.length > 0 ? (
89!
674
      <i
675
        aria-label={l10n.getString("whatsnew-counter-label", {
676
          count: newEntries.length,
677
        })}
678
        className={styles.pill}
679
      >
680
        {newEntries.length}
681
      </i>
682
    ) : null;
683

684
  return (
685
    <>
686
      {overlayBugWorkaround}
687
      <button
688
        {...buttonProps}
689
        ref={triggerRef}
690
        className={`${styles.trigger} ${
691
          triggerState.isOpen ? styles["is-open"] : ""
89!
692
        } ${props.style}`}
693
      >
694
        <GiftIcon
695
          className={styles["trigger-icon"]}
696
          alt={l10n.getString("whatsnew-trigger-label")}
697
        />
698
        <span className={styles["trigger-label"]}>
699
          {l10n.getString("whatsnew-trigger-label")}
700
        </span>
701
        {pill}
702
      </button>
703
      {triggerState.isOpen && (
89✔
704
        <OverlayContainer>
705
          <WhatsNewPopover
706
            {...overlayProps}
707
            {...positionProps}
708
            ref={overlayRef}
709
            title={l10n.getString("whatsnew-trigger-label")}
710
            isOpen={triggerState.isOpen}
711
            onClose={() => triggerState.close()}
×
712
          >
713
            <WhatsNewDashboard
714
              new={newEntries}
715
              archive={entriesNotInFuture}
716
              onClose={() => triggerState.close()}
×
717
            />
718
          </WhatsNewPopover>
719
        </OverlayContainer>
720
      )}
721
    </>
722
  );
723
};
724

725
type PopoverProps = {
726
  title: string;
727
  children: ReactNode;
728
  isOpen: boolean;
729
  onClose: () => void;
730
} & HTMLAttributes<HTMLDivElement>;
731
const WhatsNewPopover = forwardRef<HTMLDivElement, PopoverProps>(
8✔
732
  ({ title, children, isOpen, onClose, ...otherProps }, ref) => {
733
    const { overlayProps } = useOverlay(
×
734
      {
735
        onClose: onClose,
736
        isOpen: isOpen,
737
        isDismissable: true,
738
      },
739
      ref as RefObject<HTMLDivElement>,
740
    );
741

742
    const { modalProps } = useModal();
×
743

744
    const { dialogProps, titleProps } = useDialog(
×
745
      {},
746
      ref as RefObject<HTMLDivElement>,
747
    );
748

749
    const mergedOverlayProps = mergeProps(
×
750
      overlayProps,
751
      dialogProps,
752
      otherProps,
753
      modalProps,
754
    );
755

756
    return (
757
      <FocusScope restoreFocus contain autoFocus>
758
        <div
759
          {...mergedOverlayProps}
760
          ref={ref}
761
          className={styles["popover-wrapper"]}
762
        >
763
          <VisuallyHidden>
764
            <h2 {...titleProps}>{title}</h2>
765
          </VisuallyHidden>
766
          {children}
767
          <DismissButton onDismiss={onClose} />
768
        </div>
769
      </FocusScope>
770
    );
771
  },
772
);
773
WhatsNewPopover.displayName = "WhatsNewPopover";
8✔
774

775
const entriesDescByDateSorter: Parameters<Array<WhatsNewEntry>["sort"]>[0] = (
8✔
776
  entryA,
777
  entryB,
778
) => {
779
  const dateANr =
780
    entryA.announcementDate.year +
177✔
781
    entryA.announcementDate.month / 100 +
782
    entryA.announcementDate.day / 10000;
783
  const dateBNr =
784
    entryB.announcementDate.year +
177✔
785
    entryB.announcementDate.month / 100 +
786
    entryB.announcementDate.day / 10000;
787

788
  return dateBNr - dateANr;
177✔
789
};
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