• 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

66.32
/frontend/src/components/dashboard/aliases/AliasGenerationButton.tsx
1
import {
2
  FocusScope,
3
  useOverlay,
4
  useMenuTrigger,
5
  useMenu,
6
  DismissButton,
7
  mergeProps,
8
  useMenuItem,
9
  useFocus,
10
  useButton,
11
  AriaOverlayProps,
12
} from "react-aria";
3✔
13
import { AriaMenuItemProps } from "@react-aria/menu";
14
import { event as gaEvent } from "react-ga";
3✔
15
import {
16
  Item,
17
  MenuTriggerState,
18
  TreeProps,
19
  TreeState,
20
  useMenuTriggerState,
21
  useOverlayTriggerState,
22
  useTreeState,
23
} from "react-stately";
3✔
24
import {
25
  HTMLAttributes,
26
  Key,
27
  ReactNode,
28
  useEffect,
29
  useRef,
30
  useState,
31
} from "react";
3✔
32
import styles from "./AliasGenerationButton.module.scss";
3✔
33
import { ArrowDownIcon, PlusIcon } from "../../Icons";
3✔
34
import { ProfileData } from "../../../hooks/api/profile";
35
import { Button, LinkButton } from "../../Button";
3✔
36
import { AliasData } from "../../../hooks/api/aliases";
37
import { getRuntimeConfig } from "../../../config";
3✔
38
import { RuntimeData } from "../../../hooks/api/runtimeData";
39
import { isPeriodicalPremiumAvailableInCountry } from "../../../functions/getPlan";
3✔
40
import { useGaViewPing } from "../../../hooks/gaViewPing";
3✔
41
import { CustomAddressGenerationModal } from "./CustomAddressGenerationModal";
3✔
42
import { useL10n } from "../../../hooks/l10n";
3✔
43
import { isFlagActive } from "../../../functions/waffle";
3✔
44
import { AddressPickerModal } from "./AddressPickerModal";
3✔
45

46
export type Props = {
47
  aliases: AliasData[];
48
  profile: ProfileData;
49
  runtimeData?: RuntimeData;
50
  onCreate: (
51
    options:
52
      | { mask_type: "random" }
53
      | { mask_type: "custom"; address: string; blockPromotionals: boolean },
54
    setAliasGeneratedState?: (flag: boolean) => void,
55
  ) => void;
56
  onUpdate: (alias: AliasData, updatedFields: Partial<AliasData>) => void;
57
  findAliasDataFromPrefix: (aliasPrefix: string) => AliasData | undefined;
58
  setGeneratedAlias: (alias: AliasData | undefined) => void;
59
};
60

61
/**
62
 * A button to initiate the different flows for creating an alias.
63
 *
64
 * Usually, this will be a simple button to generate a new random alias,
65
 * but it adapts to the situation to e.g. prompt the user to upgrade to Premium
66
 * when they run out of aliases, or to allow generating a custom alias if the
67
 * user is able to.
68
 */
69
export const AliasGenerationButton = (props: Props) => {
169✔
70
  const l10n = useL10n();
165✔
71
  const getUnlimitedButtonRef = useGaViewPing({
165✔
72
    category: "Purchase Button",
73
    label: "profile-create-alias-upgrade-promo",
74
  });
75

76
  const maxAliases = getRuntimeConfig().maxFreeAliases;
165✔
77
  if (!props.profile.has_premium && props.aliases.length >= maxAliases) {
165✔
78
    // If the user does not have Premium, has reached the alias limit,
79
    // and Premium is not available to them, show a greyed-out button:
80
    if (!isPeriodicalPremiumAvailableInCountry(props.runtimeData)) {
11✔
81
      return (
82
        <Button disabled>
83
          <PlusIcon alt="" width={16} height={16} />
84
          {l10n.getString("profile-label-generate-new-alias-2")}
85
        </Button>
86
      );
87
    }
88

89
    // If the user does not have Premium, has reached the alias limit,
90
    // and Premium is available to them, prompt them to upgrade:
91
    return (
92
      <LinkButton
93
        href="/premium#pricing"
94
        ref={getUnlimitedButtonRef}
95
        onClick={() => {
96
          gaEvent({
×
97
            category: "Purchase Button",
98
            action: "Engage",
99
            label: "profile-create-alias-upgrade-promo",
100
          });
101
        }}
102
      >
103
        {l10n.getString("profile-label-upgrade-2")}
104
      </LinkButton>
105
    );
106
  }
107

108
  if (
154✔
109
    props.profile.has_premium &&
179✔
110
    typeof props.profile.subdomain === "string"
111
  ) {
112
    return (
113
      <AliasTypeMenu
114
        onCreate={props.onCreate}
115
        onUpdate={props.onUpdate}
116
        subdomain={props.profile.subdomain}
117
        findAliasDataFromPrefix={props.findAliasDataFromPrefix}
118
        setGeneratedAlias={props.setGeneratedAlias}
119
        runtimeData={props.runtimeData}
120
      />
121
    );
122
  }
123

124
  return (
125
    <Button
126
      onClick={() => props.onCreate({ mask_type: "random" })}
×
127
      title={l10n.getString("profile-label-generate-new-alias-2")}
128
    >
129
      <PlusIcon alt="" width={16} height={16} />
130
      {l10n.getString("profile-label-generate-new-alias-2")}
131
    </Button>
132
  );
133
};
134

135
type AliasTypeMenuProps = {
136
  subdomain: string;
137
  onCreate: (
138
    options:
139
      | { mask_type: "random" }
140
      | { mask_type: "custom"; address: string; blockPromotionals: boolean },
141
    setAliasGeneratedState?: (flag: boolean) => void,
142
  ) => void;
143
  onUpdate: (alias: AliasData, updatedFields: Partial<AliasData>) => void;
144
  findAliasDataFromPrefix: (aliasPrefix: string) => AliasData | undefined;
145
  setGeneratedAlias: (alias: AliasData | undefined) => void;
146
  runtimeData?: RuntimeData;
147
};
148
const AliasTypeMenu = (props: AliasTypeMenuProps) => {
3✔
149
  const l10n = useL10n();
3✔
150
  const modalState = useOverlayTriggerState({});
3✔
151
  const [aliasGeneratedState, setAliasGeneratedState] = useState(false);
3✔
152

153
  const onAction = (key: Key) => {
3✔
154
    if (key === "random") {
×
155
      props.onCreate({ mask_type: "random" });
×
156
      return;
×
157
    }
158
    if (key === "custom") {
×
159
      modalState.open();
×
160
    }
161
  };
162

163
  const onPick = (address: string, setErrorState: (flag: boolean) => void) => {
3✔
164
    props.onCreate(
×
165
      {
166
        mask_type: "custom",
167
        address: address,
168
        blockPromotionals: false,
169
      },
170
      (isCreated: boolean) => {
171
        setAliasGeneratedState(isCreated);
×
172
        if (!isCreated) {
×
173
          setErrorState(true);
×
174
        } // Shows the error banner within the modal
175
      },
176
    );
177
  };
178

179
  const onPickNonRedesign = (
3✔
180
    address: string,
181
    settings: { blockPromotionals: boolean },
182
  ) => {
183
    props.onCreate({
×
184
      mask_type: "custom",
185
      address: address,
186
      blockPromotionals: settings.blockPromotionals,
187
    });
188
    modalState.close();
×
189
  };
190

191
  const onSuccessClose = (
3✔
192
    aliasToUpdate: AliasData | undefined,
193
    blockPromotions: boolean,
194
    copyToClipboard: boolean | undefined,
195
  ) => {
196
    if (aliasToUpdate && blockPromotions) {
×
197
      props.onUpdate(aliasToUpdate, {
×
198
        enabled: true,
199
        block_list_emails: blockPromotions,
200
      });
201
    }
202
    if (copyToClipboard) {
×
203
      props.setGeneratedAlias(aliasToUpdate);
×
204
    }
205
    modalState.close();
×
206
  };
207

208
  const dialog = modalState.isOpen ? (
3!
209
    isFlagActive(props.runtimeData, "custom_domain_management_redesign") ? (
×
210
      <CustomAddressGenerationModal
211
        isOpen={modalState.isOpen}
212
        onClose={() => modalState.close()}
×
213
        onUpdate={onSuccessClose}
214
        onPick={onPick}
215
        subdomain={props.subdomain}
216
        aliasGeneratedState={aliasGeneratedState}
217
        findAliasDataFromPrefix={props.findAliasDataFromPrefix}
218
      />
219
    ) : (
220
      <AddressPickerModal
221
        isOpen={modalState.isOpen}
222
        onClose={() => modalState.close()}
×
223
        onPick={onPickNonRedesign}
224
        subdomain={props.subdomain}
225
      />
226
    )
227
  ) : null;
228

229
  useEffect(() => {
3✔
230
    if (!modalState.isOpen) {
3✔
231
      setAliasGeneratedState(false);
3✔
232
    }
233
  }, [modalState]);
234

235
  return (
236
    <>
237
      <AliasTypeMenuButton onAction={onAction}>
238
        <Item key="random">
239
          {l10n.getString("profile-label-generate-new-alias-menu-random-2")}
240
        </Item>
241
        <Item key="custom">
242
          {l10n.getString("profile-label-generate-new-alias-menu-custom-2", {
243
            subdomain: props.subdomain,
244
          })}
245
        </Item>
246
      </AliasTypeMenuButton>
247
      {dialog}
248
    </>
249
  );
250
};
251

252
type AliasTypeMenuButtonProps = Parameters<typeof useMenuTriggerState>[0] & {
253
  children: TreeProps<Record<string, never>>["children"];
254
  onAction: AriaMenuItemProps["onAction"];
255
};
256
const AliasTypeMenuButton = (props: AliasTypeMenuButtonProps) => {
3✔
257
  const l10n = useL10n();
7✔
258
  const triggerState = useMenuTriggerState(props);
7✔
259
  const triggerRef = useRef<HTMLButtonElement>(null);
7✔
260
  const { menuTriggerProps, menuProps } = useMenuTrigger(
7✔
261
    {},
262
    triggerState,
263
    triggerRef,
264
  );
265
  // `menuProps` has an `autoFocus` property that is not compatible with the
266
  // `autoFocus` property for HTMLElements, because it can also be of type
267
  // `FocusStrategy` (i.e. the string "first" or "last") at the time of writing.
268
  // Since its values get spread onto an HTMLUListElement, we ignore those
269
  // values. See
270
  // https://github.com/mozilla/fx-private-relay/pull/3261#issuecomment-1493840024
271
  const menuPropsWithoutAutofocus = {
7✔
272
    ...menuProps,
273
    autoFocus:
274
      typeof menuProps.autoFocus === "boolean"
7!
275
        ? menuProps.autoFocus
276
        : undefined,
277
  };
278

279
  const triggerButtonProps = useButton(
7✔
280
    menuTriggerProps,
281
    triggerRef,
282
  ).buttonProps;
283

284
  return (
285
    <div className={styles["button-wrapper"]}>
286
      <Button ref={triggerRef} {...triggerButtonProps}>
287
        {l10n.getString("profile-label-generate-new-alias-2")}
288
        <ArrowDownIcon alt="" width={16} height={16} />
289
      </Button>
290
      {triggerState.isOpen && (
7✔
291
        <AliasTypeMenuPopup
292
          {...props}
293
          aria-label={l10n.getString("profile-label-generate-new-alias-2")}
294
          domProps={menuPropsWithoutAutofocus}
295
          autoFocus={triggerState.focusStrategy}
296
          onClose={() => triggerState.close()}
×
297
        />
298
      )}
299
    </div>
300
  );
301
};
302

303
type AliasTypeMenuPopupProps = TreeProps<Record<string, never>> & {
304
  onAction: AriaMenuItemProps["onAction"];
305
  domProps: HTMLAttributes<HTMLElement>;
306
  onClose?: AriaOverlayProps["onClose"];
307
  autoFocus?: MenuTriggerState["focusStrategy"];
308
};
309
const AliasTypeMenuPopup = (props: AliasTypeMenuPopupProps) => {
3✔
310
  const popupState = useTreeState({ ...props, selectionMode: "none" });
4✔
311

312
  const popupRef = useRef<HTMLUListElement>(null);
4✔
313
  const popupProps = useMenu(props, popupState, popupRef).menuProps;
4✔
314

315
  const overlayRef = useRef<HTMLDivElement>(null);
4✔
316
  const { overlayProps } = useOverlay(
4✔
317
    {
318
      onClose: props.onClose,
319
      shouldCloseOnBlur: true,
320
      isOpen: true,
321
      isDismissable: true,
322
    },
323
    overlayRef,
324
  );
325

326
  // <FocusScope> ensures that focus is restored back to the
327
  // trigger when the menu is closed.
328
  // The <DismissButton> components allow screen reader users
329
  // to dismiss the popup easily.
330
  return (
331
    <FocusScope restoreFocus>
332
      <div {...overlayProps} ref={overlayRef}>
333
        <DismissButton onDismiss={props.onClose} />
334
        <ul
335
          {...mergeProps(popupProps, props.domProps)}
336
          ref={popupRef}
337
          className={styles.popup}
338
        >
339
          {Array.from(popupState.collection).map((item) => (
340
            <AliasTypeMenuItem
8✔
341
              key={item.key}
342
              // TODO: Fix the typing (likely: report to react-aria that the type does not include an isDisabled prop)
343
              item={item as unknown as AliasTypeMenuItemProps["item"]}
344
              state={popupState}
345
              onAction={props.onAction}
346
              onClose={props.onClose}
347
            />
348
          ))}
349
        </ul>
350
        <DismissButton onDismiss={props.onClose} />
351
      </div>
352
    </FocusScope>
353
  );
354
};
355

356
type AliasTypeMenuItemProps = {
357
  // TODO: Figure out correct type:
358
  item: {
359
    key: AriaMenuItemProps["key"];
360
    isDisabled: AriaMenuItemProps["isDisabled"];
361
    rendered?: ReactNode;
362
  };
363
  state: TreeState<unknown>;
364
  onAction: AriaMenuItemProps["onAction"];
365
  onClose: AriaMenuItemProps["onClose"];
366
};
367

368
const AliasTypeMenuItem = (props: AliasTypeMenuItemProps) => {
3✔
369
  const menuItemRef = useRef<HTMLLIElement>(null);
12✔
370
  const menuItemProps = useMenuItem(
12✔
371
    {
372
      key: props.item.key,
373
      isDisabled: props.item.isDisabled,
374
      onAction: props.onAction,
375
      onClose: props.onClose,
376
    },
377
    props.state,
378
    menuItemRef,
379
  ).menuItemProps;
380

381
  const [_isFocused, setIsFocused] = useState(false);
12✔
382
  const focusProps = useFocus({ onFocusChange: setIsFocused }).focusProps;
12✔
383

384
  return (
385
    <li
386
      {...mergeProps(menuItemProps, focusProps)}
387
      ref={menuItemRef}
388
      className={styles["menu-item-wrapper"]}
389
    >
390
      {props.item.rendered}
391
    </li>
392
  );
393
};
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