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

mozilla / fx-private-relay / dd190f18-97f3-4530-851c-37b943e6d7f9

19 Feb 2025 09:57PM CUT coverage: 85.114% (-0.003%) from 85.117%
dd190f18-97f3-4530-851c-37b943e6d7f9

push

circleci

web-flow
Merge pull request #5335 from mozilla/dependabot/npm_and_yarn/react-65d453da54

Bump the react group across 1 directory with 4 updates

2433 of 3561 branches covered (68.32%)

Branch coverage included in aggregate %.

14 of 17 new or added lines in 11 files covered. (82.35%)

1 existing line in 1 file now uncovered.

17047 of 19326 relevant lines covered (88.21%)

9.88 hits per line

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

54.12
/frontend/src/components/dashboard/tips/Tips.tsx
1
import { useRef, ReactNode, useState, useCallback, RefObject } from "react";
2✔
2
import Link from "next/link";
2✔
3
import { useTabList, useTabPanel, useTab } from "react-aria";
2✔
4
import { useTabListState, TabListState, Item } from "react-stately";
2✔
5
import { useInView } from "react-intersection-observer";
2✔
6
import styles from "./Tips.module.scss";
2✔
7
import MultiRepliesImage from "./images/multi-replies.svg";
2✔
8
import { ArrowDownIcon, InfoIcon } from "../../Icons";
2✔
9
import { ProfileData } from "../../../hooks/api/profile";
10
import {
11
  DismissalData,
12
  useLocalDismissal,
13
} from "../../../hooks/localDismissal";
2✔
14
import { getRuntimeConfig } from "../../../config";
2✔
15
import { CustomAliasTip } from "./CustomAliasTip";
2✔
16
import { useGaViewPing } from "../../../hooks/gaViewPing";
2✔
17
import { useRelayNumber } from "../../../hooks/api/relayNumber";
2✔
18
import { RuntimeData } from "../../../hooks/api/runtimeData";
19
import { isFlagActive } from "../../../functions/waffle";
2✔
20
import { GenericTip } from "./GenericTip";
2✔
21
import { useGaEvent } from "../../../hooks/gaEvent";
2✔
22
import { useL10n } from "../../../hooks/l10n";
2✔
23

24
export type Props = {
25
  profile: ProfileData;
26
  runtimeData: RuntimeData;
27
};
28

29
export type TipEntry = {
30
  /** This ID is used to identify the tip in analytics. */
31
  id: string;
32
  title: string;
33
  content: ReactNode;
34
  dismissal: DismissalData;
35
};
36

37
/**
38
 * Panel to be used on the bottom of the page, displaying tips relevant to the user.
39
 */
40
export const Tips = (props: Props) => {
48✔
41
  const l10n = useL10n();
48✔
42
  const [isExpanded, setIsExpanded] = useState(false);
48✔
43
  const [wrapperRef, wrapperIsInView] = useInView({ threshold: 1 });
48✔
44
  const relayNumberData = useRelayNumber({ disable: !props.profile.has_phone });
48✔
45
  const gaEvent = useGaEvent();
48✔
46

47
  const tips: TipEntry[] = [];
48✔
48

49
  // If the user has set up a Relay phone number, tell them how they can reply
50
  // to multiple senders:
51
  const multiRepliesTip: TipEntry = {
48✔
52
    id: "multi-replies",
53
    title: l10n.getString("tips-multi-replies-heading"),
54
    content: (
55
      <GenericTip
56
        title={l10n.getString("tips-multi-replies-heading")}
57
        content={l10n.getString("tips-multi-replies-content")}
58
        videos={{
59
          // Unfortunately video files cannot currently be imported, so make
60
          // sure these files are present in /public. See
61
          // https://github.com/vercel/next.js/issues/35248
62
          "video/webm; codecs='vp9'": "/animations/tips/multi-replies.webm",
63
          "video/mp4": "/animations/tips/multi-replies.mp4",
64
        }}
65
        image={MultiRepliesImage}
66
        // Not localised, because the video is only shown to English speakers:
67
        alt="To reply to the phone number 555-555-9876, type 9876 then type your message"
68
      />
69
    ),
70
    dismissal: useLocalDismissal(`tips_multiReplies_${props.profile.id}`),
71
  };
72
  if (
48!
73
    isFlagActive(props.runtimeData, "multi_replies") &&
48!
74
    props.profile.has_phone &&
75
    Array.isArray(relayNumberData.data) &&
76
    relayNumberData.data.length > 0
77
  ) {
78
    tips.push(multiRepliesTip);
×
79
  }
80

81
  // If the user has Premium, show a tip about how to claim a custom subdomain:
82
  const customAliasDismissal = useLocalDismissal(
48✔
83
    `tips_customAlias_${props.profile.id}`,
84
  );
85
  const customMaskTip = {
48✔
86
    id: "custom-subdomain",
87
    title: l10n.getString("tips-custom-alias-heading-2"),
88
    content: (
89
      <CustomAliasTip subdomain={props.profile.subdomain ?? undefined} />
94✔
90
    ),
91
    dismissal: customAliasDismissal,
92
  };
93
  if (props.profile.has_premium) {
48✔
94
    tips.push(customMaskTip);
19✔
95
  }
96

97
  if (tips.length === 0) {
48✔
98
    return null;
29✔
99
  }
100

101
  const minimise = () => {
19✔
102
    tips.forEach((tipEntry) => {
×
103
      tipEntry.dismissal.dismiss();
×
104
    });
105
    setIsExpanded(false);
×
106
    gaEvent({
×
107
      category: "Tips",
108
      action: "Collapse",
109
      label: "tips-header",
110
    });
111
  };
112

113
  let elementToShow = (
114
    <div className={styles.card}>
115
      <header className={styles.header}>
116
        <span className={styles.icon}>
117
          <InfoIcon alt="" width={20} height={20} />
118
        </span>
119
        <h2>{l10n.getString("tips-header-title")}</h2>
120
        <button onClick={() => minimise()} className={styles["close-button"]}>
×
121
          <ArrowDownIcon
122
            alt={l10n.getString("tips-header-button-close-label")}
123
            width={20}
124
            height={20}
125
          />
126
        </button>
127
      </header>
128
      <div className={styles["tip-carousel"]}>
129
        <TipsCarousel defaultSelectedKey={tips[0].id}>
130
          {tips.map((tip) => (
131
            <Item key={tip.id}>{tip.content}</Item>
19✔
132
          ))}
133
        </TipsCarousel>
134
      </div>
135
      <footer className={styles.footer}>
136
        <ul>
137
          <li>
138
            <Link
139
              href="/faq"
140
              title={l10n.getString("tips-footer-link-faq-tooltip")}
141
            >
142
              {l10n.getString("tips-footer-link-faq-label")}
143
            </Link>
144
          </li>
145
          <li>
146
            <a
147
              href={`https://support.mozilla.org/products/relay?utm_source=${
148
                getRuntimeConfig().frontendOrigin
149
              }`}
150
              target="_blank"
151
              rel="noopener noreferrer"
152
              title={l10n.getString("tips-footer-link-support-tooltip")}
153
            >
154
              {l10n.getString("tips-footer-link-support-label")}
155
            </a>
156
          </li>
157
        </ul>
158
      </footer>
159
    </div>
160
  );
161

162
  if (!isExpanded) {
19✔
163
    elementToShow = tips.every((tipEntry) => tipEntry.dismissal.isDismissed) ? (
19✔
164
      // If there are no active tips that have not been seen yet,
165
      // just show a small button that allows pulling up the panel again:
166
      <button
×
167
        className={styles["expand-button"]}
168
        onClick={() => {
169
          setIsExpanded(true);
×
170
          gaEvent({
×
171
            category: "Tips",
172
            action: "Expand (from minimised)",
173
            label: tips[0].id,
174
          });
175
        }}
176
      >
177
        <span className={styles.icon}>
178
          <InfoIcon alt="" width={20} height={20} />
179
        </span>
180
        <span>{l10n.getString("tips-header-title")}</span>
181
      </button>
182
    ) : (
183
      <div className={styles.card}>
184
        <header className={styles.header}>
185
          <span className={styles.icon}>
186
            <InfoIcon alt="" width={20} height={20} />
187
          </span>
188
          <h2>{l10n.getString("tips-header-title")}</h2>
189
          <button
190
            onClick={() => minimise()}
×
191
            className={styles["close-button"]}
192
            aria-label={l10n.getString("tips-header-button-close-label")}
193
          >
194
            <ArrowDownIcon
195
              alt={l10n.getString("tips-header-button-close-label")}
196
              width={20}
197
              height={20}
198
            />
199
          </button>
200
        </header>
201
        <div className={styles.summary}>
202
          <b>{tips[0].title}</b>
203
          <button
204
            onClick={() => {
205
              setIsExpanded(true);
×
206
              gaEvent({
×
207
                category: "Tips",
208
                action: "Expand (from teaser)",
209
                label: tips[0].id,
210
              });
211
            }}
212
          >
213
            {l10n.getString("tips-toast-button-expand-label")}
214
          </button>
215
        </div>
216
      </div>
217
    );
218
  }
219

220
  return (
221
    <aside
222
      ref={wrapperRef}
223
      aria-label={l10n.getString("tips-header-title")}
224
      className={`${styles.wrapper} ${
225
        wrapperIsInView ? styles["is-in-view"] : styles["is-out-of-view"]
19!
226
      }`}
227
    >
228
      {elementToShow}
229
    </aside>
230
  );
231
};
232

233
const TipsCarousel = (props: Parameters<typeof useTabListState>[0]) => {
2✔
234
  const tabListState = useTabListState(props);
×
235
  const tabListRef = useRef<HTMLDivElement>(null);
×
236
  const { tabListProps } = useTabList(
×
237
    { ...props, orientation: "horizontal" },
238
    tabListState,
239
    tabListRef,
240
  );
241

242
  const tipSwitcher =
243
    tabListState.collection.size === 1
×
244
      ? null
245
      : Array.from(tabListState.collection).map((item) => (
246
          <PanelDot key={item.key} item={item} tabListState={tabListState} />
×
247
        ));
248

249
  return (
250
    <div>
251
      <TipPanel
252
        key={tabListState.selectedItem?.key}
253
        tabListState={tabListState}
254
      />
255
      <div
256
        {...tabListProps}
257
        ref={tabListRef}
258
        className={styles["tip-switcher"]}
259
      >
260
        {tipSwitcher}
261
      </div>
262
    </div>
263
  );
264
};
265

266
type PanelDotProps = {
267
  item: {
268
    key: Parameters<typeof useTab>[0]["key"];
269
    rendered: ReactNode;
270
    index?: number;
271
  };
272
  tabListState: TabListState<object>;
273
};
274
const PanelDot = (props: PanelDotProps) => {
2✔
275
  const l10n = useL10n();
×
276
  const dotRef = useRef<HTMLDivElement>(null);
×
277
  const { tabProps } = useTab(
×
278
    { key: props.item.key },
279
    props.tabListState,
280
    dotRef,
281
  );
282
  const isSelected = props.tabListState.selectedKey === props.item.key;
×
283
  const alt = l10n.getString("tips-switcher-label", {
×
284
    nr: (props.item.index ?? 0) + 1,
×
285
  });
286
  return (
287
    <div
288
      {...tabProps}
289
      ref={dotRef}
290
      className={`${styles["panel-dot"]} ${
291
        isSelected ? styles["is-selected"] : ""
×
292
      }`}
293
    >
294
      <div className={styles["focus-wrapper"]}>
295
        <svg
296
          role="img"
297
          aria-label={alt}
298
          xmlns="http://www.w3.org/2000/svg"
299
          viewBox="0 0 8 8"
300
          width={8}
301
          height={8}
302
        >
303
          <title>{alt}</title>
304
          <circle
305
            style={{
306
              fill: "currentcolor",
307
            }}
308
            cx="4"
309
            cy="4"
310
            r="4"
311
          />
312
        </svg>
313
      </div>
314
    </div>
315
  );
316
};
317

318
const TipPanel = ({
2✔
319
  tabListState,
320
  ...props
321
}: { tabListState: TabListState<object> } & Parameters<
322
  typeof useTabPanel
323
>[0]) => {
NEW
324
  const panelRef = useRef<HTMLDivElement | null>(null);
×
325
  const inViewRef = useGaViewPing({
×
326
    category: "Tips",
327
    label: tabListState.selectedItem?.key.toString(),
328
  });
329
  // Used to set both `panelRef` and `useGaViewPing`'s callback ref on the
330
  // same element. See
331
  // https://github.com/thebuilder/react-intersection-observer/blob/d61319a06084d660c1b390c2ccdcd2e4bdaa002e/README.md#how-can-i-assign-multiple-refs-to-a-component
332
  const setRefs = useCallback(
×
333
    (element: HTMLDivElement) => {
334
      panelRef.current = element;
×
335
      inViewRef(element);
×
336
    },
337
    [inViewRef],
338
  );
339

340
  const { tabPanelProps } = useTabPanel(
×
341
    props,
342
    tabListState,
343
    // useTabPanel's type definition expects a RefObject,
344
    // but because we're using a MutableRefObject (due to useGaViewPing, by
345
    // virtue of its use of useInView, not accepting an existing Ref), we need
346
    // to explicitly tell it that that, too, is a Ref:
347
    panelRef as RefObject<HTMLDivElement>,
348
  );
349

350
  return (
351
    <div {...tabPanelProps} ref={setRefs} className={styles.tip}>
352
      {tabListState.selectedItem?.props.children}
353
    </div>
354
  );
355
};
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