frontend/src/components/dashboard/aliases/AliasGenerationButton.tsx (327 lines of code) (raw):
import {
FocusScope,
useOverlay,
useMenuTrigger,
DismissButton,
mergeProps,
useMenuItem,
useFocus,
useButton,
} from "react-aria";
import { AriaMenuItemProps } from "@react-aria/menu";
import {
Item,
TreeProps,
TreeState,
useMenuTriggerState,
useOverlayTriggerState,
useTreeState,
} from "react-stately";
import { Key, ReactNode, useEffect, useRef, useState } from "react";
import styles from "./AliasGenerationButton.module.scss";
import { ArrowDownIcon, PlusIcon } from "../../Icons";
import { ProfileData } from "../../../hooks/api/profile";
import { Button, LinkButton } from "../../Button";
import { AliasData } from "../../../hooks/api/aliases";
import { getRuntimeConfig } from "../../../config";
import { RuntimeData } from "../../../hooks/api/runtimeData";
import { isPeriodicalPremiumAvailableInCountry } from "../../../functions/getPlan";
import { useGaViewPing } from "../../../hooks/gaViewPing";
import { CustomAddressGenerationModal } from "./CustomAddressGenerationModal";
import { useGaEvent } from "../../../hooks/gaEvent";
import { useL10n } from "../../../hooks/l10n";
import { isFlagActive } from "../../../functions/waffle";
import { MenuPopupProps, useMenu } from "../../../hooks/menu";
import { AddressPickerModal } from "./AddressPickerModal";
export type Props = {
aliases: AliasData[];
profile: ProfileData;
runtimeData?: RuntimeData;
onCreate: (
options:
| { mask_type: "random" }
| { mask_type: "custom"; address: string; blockPromotionals: boolean },
setAliasGeneratedState?: (flag: boolean) => void,
) => void;
onUpdate: (alias: AliasData, updatedFields: Partial<AliasData>) => void;
findAliasDataFromPrefix: (aliasPrefix: string) => AliasData | undefined;
setGeneratedAlias: (alias: AliasData | undefined) => void;
};
/**
* A button to initiate the different flows for creating an alias.
*
* Usually, this will be a simple button to generate a new random alias,
* but it adapts to the situation to e.g. prompt the user to upgrade to Premium
* when they run out of aliases, or to allow generating a custom alias if the
* user is able to.
*/
export const AliasGenerationButton = (props: Props) => {
const l10n = useL10n();
const getUnlimitedButtonRef = useGaViewPing({
category: "Purchase Button",
label: "profile-create-alias-upgrade-promo",
});
const gaEvent = useGaEvent();
const maxAliases = getRuntimeConfig().maxFreeAliases;
if (!props.profile.has_premium && props.aliases.length >= maxAliases) {
// If the user does not have Premium, has reached the alias limit,
// and Premium is not available to them, show a greyed-out button:
if (!isPeriodicalPremiumAvailableInCountry(props.runtimeData)) {
return (
<Button disabled>
<PlusIcon alt="" width={16} height={16} />
{l10n.getString("profile-label-generate-new-alias-2")}
</Button>
);
}
// If the user does not have Premium, has reached the alias limit,
// and Premium is available to them, prompt them to upgrade:
return (
<LinkButton
href="/premium#pricing"
ref={getUnlimitedButtonRef}
onClick={() => {
gaEvent({
category: "Purchase Button",
action: "Engage",
label: "profile-create-alias-upgrade-promo",
});
}}
>
{l10n.getString("profile-label-upgrade-2")}
</LinkButton>
);
}
if (
props.profile.has_premium &&
typeof props.profile.subdomain === "string"
) {
return (
<AliasTypeMenu
onCreate={props.onCreate}
onUpdate={props.onUpdate}
subdomain={props.profile.subdomain}
findAliasDataFromPrefix={props.findAliasDataFromPrefix}
setGeneratedAlias={props.setGeneratedAlias}
runtimeData={props.runtimeData}
/>
);
}
return (
<Button
onClick={() => props.onCreate({ mask_type: "random" })}
title={l10n.getString("profile-label-generate-new-alias-2")}
>
<PlusIcon alt="" width={16} height={16} />
{l10n.getString("profile-label-generate-new-alias-2")}
</Button>
);
};
type AliasTypeMenuProps = {
subdomain: string;
onCreate: (
options:
| { mask_type: "random" }
| { mask_type: "custom"; address: string; blockPromotionals: boolean },
setAliasGeneratedState?: (flag: boolean) => void,
) => void;
onUpdate: (alias: AliasData, updatedFields: Partial<AliasData>) => void;
findAliasDataFromPrefix: (aliasPrefix: string) => AliasData | undefined;
setGeneratedAlias: (alias: AliasData | undefined) => void;
runtimeData?: RuntimeData;
};
const AliasTypeMenu = (props: AliasTypeMenuProps) => {
const l10n = useL10n();
const modalState = useOverlayTriggerState({});
const [aliasGeneratedState, setAliasGeneratedState] = useState(false);
const onAction = (key: Key) => {
if (key === "random") {
props.onCreate({ mask_type: "random" });
return;
}
if (key === "custom") {
modalState.open();
}
};
const onPick = (address: string, setErrorState: (flag: boolean) => void) => {
props.onCreate(
{
mask_type: "custom",
address: address,
blockPromotionals: false,
},
(isCreated: boolean) => {
setAliasGeneratedState(isCreated);
if (!isCreated) {
setErrorState(true);
} // Shows the error banner within the modal
},
);
};
const onPickNonRedesign = (
address: string,
settings: { blockPromotionals: boolean },
) => {
props.onCreate({
mask_type: "custom",
address: address,
blockPromotionals: settings.blockPromotionals,
});
modalState.close();
};
const onSuccessClose = (
aliasToUpdate: AliasData | undefined,
blockPromotions: boolean,
copyToClipboard: boolean | undefined,
) => {
if (aliasToUpdate && blockPromotions) {
props.onUpdate(aliasToUpdate, {
enabled: true,
block_list_emails: blockPromotions,
});
}
if (copyToClipboard) {
props.setGeneratedAlias(aliasToUpdate);
}
modalState.close();
};
const dialog = modalState.isOpen ? (
isFlagActive(props.runtimeData, "custom_domain_management_redesign") ? (
<CustomAddressGenerationModal
isOpen={modalState.isOpen}
onClose={() => modalState.close()}
onUpdate={onSuccessClose}
onPick={onPick}
subdomain={props.subdomain}
aliasGeneratedState={aliasGeneratedState}
findAliasDataFromPrefix={props.findAliasDataFromPrefix}
/>
) : (
<AddressPickerModal
isOpen={modalState.isOpen}
onClose={() => modalState.close()}
onPick={onPickNonRedesign}
subdomain={props.subdomain}
/>
)
) : null;
useEffect(() => {
if (!modalState.isOpen) {
setAliasGeneratedState(false);
}
}, [modalState]);
return (
<>
<AliasTypeMenuButton onAction={onAction}>
<Item key="random">
{l10n.getString("profile-label-generate-new-alias-menu-random-2")}
</Item>
<Item key="custom">
{l10n.getString("profile-label-generate-new-alias-menu-custom-2", {
subdomain: props.subdomain,
})}
</Item>
</AliasTypeMenuButton>
{dialog}
</>
);
};
type AliasTypeMenuButtonProps = Parameters<typeof useMenuTriggerState>[0] & {
children: TreeProps<Record<string, never>>["children"];
onAction: AriaMenuItemProps["onAction"];
};
const AliasTypeMenuButton = (props: AliasTypeMenuButtonProps) => {
const l10n = useL10n();
const triggerState = useMenuTriggerState(props);
const triggerRef = useRef<HTMLButtonElement>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger(
{},
triggerState,
triggerRef,
);
// `menuProps` has an `autoFocus` property that is not compatible with the
// `autoFocus` property for HTMLElements, because it can also be of type
// `FocusStrategy` (i.e. the string "first" or "last") at the time of writing.
// Since its values get spread onto an HTMLUListElement, we ignore those
// values. See
// https://github.com/mozilla/fx-private-relay/pull/3261#issuecomment-1493840024
const menuPropsWithoutAutofocus = {
...menuProps,
autoFocus:
typeof menuProps.autoFocus === "boolean"
? menuProps.autoFocus
: undefined,
};
const triggerButtonProps = useButton(
menuTriggerProps,
triggerRef,
).buttonProps;
return (
<div className={styles["button-wrapper"]}>
<Button ref={triggerRef} {...triggerButtonProps}>
{l10n.getString("profile-label-generate-new-alias-2")}
<ArrowDownIcon alt="" width={16} height={16} />
</Button>
{triggerState.isOpen && (
<AliasTypeMenuPopup
{...props}
aria-label={l10n.getString("profile-label-generate-new-alias-2")}
domProps={menuPropsWithoutAutofocus}
autoFocus={triggerState.focusStrategy}
onClose={() => triggerState.close()}
/>
)}
</div>
);
};
type AliasTypeMenuPopupProps = MenuPopupProps<Record<string, never>>;
const AliasTypeMenuPopup = (props: AliasTypeMenuPopupProps) => {
const popupState = useTreeState({ ...props, selectionMode: "none" });
const popupRef = useRef<HTMLUListElement>(null);
const popupProps = useMenu(props, popupState, popupRef).menuProps;
const overlayRef = useRef<HTMLDivElement>(null);
const { overlayProps } = useOverlay(
{
onClose: props.onClose,
shouldCloseOnBlur: true,
isOpen: true,
isDismissable: true,
},
overlayRef,
);
// <FocusScope> ensures that focus is restored back to the
// trigger when the menu is closed.
// The <DismissButton> components allow screen reader users
// to dismiss the popup easily.
return (
<FocusScope restoreFocus>
<div {...overlayProps} ref={overlayRef}>
<DismissButton onDismiss={props.onClose} />
<ul
{...mergeProps(popupProps, props.domProps)}
ref={popupRef}
className={styles.popup}
>
{Array.from(popupState.collection).map((item) => (
<AliasTypeMenuItem
key={item.key}
// TODO: Fix the typing (likely: report to react-aria that the type does not include an isDisabled prop)
item={item as unknown as AliasTypeMenuItemProps["item"]}
state={popupState}
onClose={props.onClose}
/>
))}
</ul>
<DismissButton onDismiss={props.onClose} />
</div>
</FocusScope>
);
};
type AliasTypeMenuItemProps = {
// TODO: Figure out correct type:
item: {
key: AriaMenuItemProps["key"];
isDisabled: AriaMenuItemProps["isDisabled"];
rendered?: ReactNode;
};
state: TreeState<unknown>;
onClose: AriaMenuItemProps["onClose"];
};
const AliasTypeMenuItem = (props: AliasTypeMenuItemProps) => {
const menuItemRef = useRef<HTMLLIElement>(null);
const menuItemProps = useMenuItem(
{
key: props.item.key,
isDisabled: props.item.isDisabled,
onClose: props.onClose,
},
props.state,
menuItemRef,
).menuItemProps;
const [_isFocused, setIsFocused] = useState(false);
const focusProps = useFocus({ onFocusChange: setIsFocused }).focusProps;
return (
<li
{...mergeProps(menuItemProps, focusProps)}
ref={menuItemRef}
className={styles["menu-item-wrapper"]}
>
{props.item.rendered}
</li>
);
};