frontend/src/components/layout/navigation/UserMenu.tsx (324 lines of code) (raw):

import { useMenuTriggerState, useTreeState, TreeProps, TreeState, Item, } from "react-stately"; import { useMenuTrigger, useButton, useOverlay, FocusScope, DismissButton, mergeProps, useMenuItem, useFocus, } from "react-aria"; import { Key, ReactNode, useRef, useState } from "react"; import { AriaMenuItemProps } from "@react-aria/menu"; import Link from "next/link"; import styles from "./UserMenu.module.scss"; import { Cogwheel, ContactIcon, NewTabIcon, SignOutIcon, SupportIcon, } from "../../Icons"; import { useUsers } from "../../../hooks/api/user"; import { useProfiles } from "../../../hooks/api/profile"; import { getRuntimeConfig } from "../../../config"; import { getCsrfToken } from "../../../functions/cookies"; import { useRuntimeData } from "../../../hooks/api/runtimeData"; import { setCookie } from "../../../functions/cookies"; import { useGaEvent } from "../../../hooks/gaEvent"; import { useL10n } from "../../../hooks/l10n"; import { MenuPopupProps, useMenu } from "../../../hooks/menu"; import React from "react"; export type Props = { style: string; }; /** * Display the user's avatar, which can open a menu allowing the user to log out or go to their settings page. */ export const UserMenu = (props: Props) => { const runtimeData = useRuntimeData(); const profileData = useProfiles(); const usersData = useUsers(); const l10n = useL10n(); const gaEvent = useGaEvent(); const itemKeys = { account: "account", settings: "settings", contact: "contact", help: "help", signout: "signout", }; const accountLinkRef = useRef<HTMLAnchorElement>(null); const settingsLinkRef = useRef<HTMLAnchorElement>(null); const contactLinkRef = useRef<HTMLAnchorElement>(null); const helpLinkRef = useRef<HTMLAnchorElement>(null); const logoutFormRef = useRef<HTMLFormElement>(null); if ( !Array.isArray(usersData.data) || usersData.data.length !== 1 || !runtimeData.data ) { // Still fetching the user's account data... return null; } const onSelect = (itemKey: Key) => { if (itemKey === itemKeys.account) { accountLinkRef.current?.click(); } if (itemKey === itemKeys.settings) { settingsLinkRef.current?.click(); } if (itemKey === itemKeys.contact) { contactLinkRef.current?.click(); } if (itemKey === itemKeys.help) { helpLinkRef.current?.click(); } if (itemKey === itemKeys.signout) { gaEvent({ category: "Sign Out", action: "Click", label: "Website Sign Out", }); setCookie("user-sign-out", "true", { maxAgeInSeconds: 60 * 60 }); logoutFormRef.current?.submit(); } }; const contactLink = profileData.data?.[0]?.has_premium === true ? ( <Item key={itemKeys.contact} textValue={l10n.getString("nav-profile-contact")} > <a ref={contactLinkRef} href={`${runtimeData.data.FXA_ORIGIN}/support/?utm_source=${ getRuntimeConfig().frontendOrigin }`} title={l10n.getString("nav-profile-contact-tooltip")} className={styles["menu-link"]} target="_blank" rel="noopener noreferrer" > <ContactIcon width={20} height={20} alt="" /> {l10n.getString("nav-profile-contact")} </a> </Item> ) : null; return ( <UserMenuTrigger style={props.style} label={ <img src={profileData.data?.[0].avatar} alt={l10n.getString("label-open-menu")} width={42} height={42} /> } onAction={onSelect} > <Item key={itemKeys.account} textValue={l10n.getString("nav-profile-manage-account")} > <span className={styles["account-menu-item"]}> <b className={styles["user-email"]}>{usersData.data[0].email}</b> <a href={`${runtimeData.data.FXA_ORIGIN}/settings/`} ref={accountLinkRef} target="_blank" rel="noopener noreferrer" className={styles["settings-link"]} > {l10n.getString("nav-profile-manage-account")} <NewTabIcon alt="" /> </a> </span> </Item> <Item key={itemKeys.settings} textValue={l10n.getString("nav-profile-settings")} > <Link href="/accounts/settings" ref={settingsLinkRef} title={l10n.getString("nav-profile-settings-tooltip")} className={styles["menu-link"]} > <Cogwheel width={20} height={20} alt="" /> {l10n.getString("nav-profile-settings")} </Link> </Item> {contactLink as React.JSX.Element} <Item key={itemKeys.help} textValue={l10n.getString("nav-profile-help")}> <a ref={helpLinkRef} href={`${getRuntimeConfig().supportUrl}?utm_source=${ getRuntimeConfig().frontendOrigin }`} title={l10n.getString("nav-profile-help-tooltip")} className={styles["menu-link"]} target="_blank" rel="noopener noreferrer" > <SupportIcon width={20} height={20} alt="" /> {l10n.getString("nav-profile-help")} </a> </Item> <Item key={itemKeys.signout} textValue={l10n.getString("nav-profile-sign-out")} > <form method="POST" action={getRuntimeConfig().fxaLogoutUrl} ref={logoutFormRef} > <input type="hidden" name="csrfmiddlewaretoken" value={getCsrfToken()} /> <button type="submit" className={styles["menu-button"]}> <SignOutIcon alt="" /> {l10n.getString("nav-profile-sign-out")} </button> </form> </Item> </UserMenuTrigger> ); }; type UserMenuTriggerProps = Parameters<typeof useMenuTriggerState>[0] & { label: ReactNode; style: string; children: TreeProps<Record<string, never>>["children"]; onAction: AriaMenuItemProps["onAction"]; }; const UserMenuTrigger = ({ label, style, ...otherProps }: UserMenuTriggerProps) => { const l10n = useL10n(); const userMenuTriggerState = useMenuTriggerState(otherProps); const triggerButtonRef = useRef<HTMLButtonElement>(null); const { menuTriggerProps, menuProps } = useMenuTrigger( {}, userMenuTriggerState, triggerButtonRef, ); // `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, triggerButtonRef, ).buttonProps; return ( <div className={`${styles.wrapper} ${style}`}> <button {...triggerButtonProps} ref={triggerButtonRef} title={l10n.getString("avatar-tooltip")} className={styles.trigger} > {label} </button> {userMenuTriggerState.isOpen && ( <UserMenuPopup {...otherProps} aria-label={l10n.getString("avatar-tooltip")} domProps={menuPropsWithoutAutofocus} autoFocus={userMenuTriggerState.focusStrategy} onClose={() => userMenuTriggerState.close()} /> )} </div> ); }; type UserMenuPopupProps = MenuPopupProps<Record<string, never>>; const UserMenuPopup = (props: UserMenuPopupProps) => { 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) => ( <UserMenuItem 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 UserMenuItemProps["item"]} state={popupState} onAction={props.onAction} onClose={props.onClose} /> ))} </ul> <DismissButton onDismiss={props.onClose} /> </div> </FocusScope> ); }; type UserMenuItemProps = { // TODO: Figure out correct type: item: { key: AriaMenuItemProps["key"]; isDisabled: AriaMenuItemProps["isDisabled"]; rendered?: ReactNode; }; state: TreeState<unknown>; onAction: AriaMenuItemProps["onAction"]; onClose: AriaMenuItemProps["onClose"]; }; const UserMenuItem = (props: UserMenuItemProps) => { const menuItemRef = useRef<HTMLLIElement>(null); const menuItemProps = useMenuItem( { key: props.item.key, isDisabled: props.item.isDisabled, onAction: props.onAction, 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> ); };