'use no memo'; // forwarded composition props invalidate the generated wrapper caches
import type { ComponentPropsWithoutRef, MouseEvent, ReactNode, Ref } from 'react';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { clsx } from 'clsx';
import { isMisleadingLink } from '#/lib/links/trust';
import { resolveUrl } from '#/lib/routes/app-links';
import { safeUrlParse } from '#/lib/utils/url';
import { groupChatJoinHandle, linkWarningDialogHandle } from '#/components/dialogs/handles';
import { useNavigationDisabled } from '#/components/NavigationDisabled';
import type { TextProps } from '#/components/Text';
import * as textStyles from '#/components/Text.css';
import { Button, type ButtonProps } from '#/components/web/Button';
import * as styles from '#/components/web/Link.css';
import { type RouteTarget, useRouter } from '#/router';
type AppRouter = ReturnType<typeof useRouter>;
// #region hooks
/** how a link navigates when pressed. `navigate` is a plain push; singleton routes dedupe themselves. */
type LinkAction = 'navigate' | 'push' | 'replace';
/**
* click callback run before navigation. return `false` to cancel navigation. do not navigate from this
* callback.
*/
type LinkOnPress = (e: MouseEvent<HTMLElement>) => false | void;
type LinkBindings = {
href: string | undefined;
onClick: (e: MouseEvent<HTMLElement>) => void;
rel?: string;
target?: string;
};
/**
* determines whether a click should defer to the browser's default action (such as opening in a new tab)
* rather than being intercepted for client-side navigation.
*
* @param event the click event to check
*/
export const isModifiedClick = (e: MouseEvent<HTMLElement>) => {
return e.altKey || e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey;
};
/**
* navigates to an in-app route `path` via the given {@link LinkAction}.
*
* @param router the router to navigate
* @param path the in-app path
* @param action how the destination enters history
* @param state structured-cloneable history state
*/
export const navigateTo = (router: AppRouter, path: string, action: LinkAction, state?: unknown): void => {
router.navigate({ replace: action === 'replace', state, to: path });
};
/** Returns a function that navigates to an in-app route `path` via the given {@link LinkAction}. */
export const useNavigateToPath = () => {
const router = useRouter();
return (path: string, action: LinkAction) => navigateTo(router, path, action);
};
/**
* resolves the href and onClick bindings for an in-app route link without rendering an anchor. this allows
* non-anchor elements to preserve link semantics (e.g., middle/cmd-click to open in a new tab, plain click to
* navigate). return false from onPress to cancel navigation.
*/
export const useInternalLink = ({
action = 'push',
onPress,
to,
}: {
action?: LinkAction;
onPress?: LinkOnPress;
to: RouteTarget;
}): LinkBindings => {
const router = useRouter();
const path = router.href(to);
const onClick = (e: MouseEvent<HTMLElement>) => {
if (onPress?.(e) === false) {
e.preventDefault();
return;
}
if (isModifiedClick(e)) {
return;
}
e.preventDefault();
navigateTo(router, path, action);
};
return { href: path, onClick };
};
// shared core for raw-URL links. `navigate` handles a plain click on a resolved internal href, and leaves an
// external or modified click to the native anchor.
const useExternalNav = (rawHref: string, action: LinkAction) => {
const navigateToPath = useNavigateToPath();
const resolved = resolveUrl(rawHref);
const href = resolved ? resolved.path : safeUrlParse(rawHref)?.href;
const isExternal = resolved === undefined;
const navigate = (e: MouseEvent<HTMLElement>) => {
if (!href || isExternal || isModifiedClick(e)) {
return;
}
e.preventDefault();
// a group-chat invite opens the join dialog in place rather than navigating to /chat/<code>.
const link = resolved?.link;
if (link?.kind === 'chatInvite') {
groupChatJoinHandle.openWithPayload({ code: link.code });
return;
}
navigateToPath(href, action);
};
return { href, isExternal, navigate };
};
const useExternalLink = ({
action = 'push',
href: rawHref,
onPress,
}: {
action?: LinkAction;
href: string;
onPress?: LinkOnPress;
}): LinkBindings => {
const { href, isExternal, navigate } = useExternalNav(rawHref, action);
const onClick = (e: MouseEvent<HTMLElement>) => {
if (onPress?.(e) === false) {
e.preventDefault();
return;
}
navigate(e);
};
return {
href,
onClick,
// external links open in a new tab; an `<a>` to a resolved in-app path needs neither.
rel: isExternal ? 'noopener noreferrer' : undefined,
target: isExternal ? '_blank' : undefined,
};
};
// like useExternalLink, but for links whose visible text is untrusted (post content): before navigating it
// verifies the text against the destination and, on a mismatch, opens the warning dialog instead.
const useContentLink = ({
action = 'push',
displayText,
href: rawHref,
onPress,
}: {
action?: LinkAction;
displayText: string;
href: string;
onPress?: LinkOnPress;
}): LinkBindings => {
const { href, isExternal, navigate } = useExternalNav(rawHref, action);
const onClick = (e: MouseEvent<HTMLElement>) => {
if (onPress?.(e) === false) {
e.preventDefault();
return;
}
if (!href) {
return;
}
if (displayText && isExternal && isMisleadingLink(href, displayText)) {
e.preventDefault();
linkWarningDialogHandle.openWithPayload({ displayText, href });
return;
}
navigate(e);
};
return {
href,
onClick,
// external links open in a new tab; an `<a>` to a resolved in-app path needs neither.
rel: isExternal ? 'noopener noreferrer' : undefined,
target: isExternal ? '_blank' : undefined,
};
};
// #endregion
// #region presentational shells
type BlockAnchorProps = Omit<ComponentPropsWithoutRef<'a'>, 'href' | 'onClick' | 'rel' | 'target'> & {
children: ReactNode;
/** Accessible name; becomes the anchor's `aria-label`. */
label?: string;
/** Forwarded to the `<a>` so the link can back a headless trigger (e.g. a hover card). */
ref?: Ref<HTMLAnchorElement>;
};
const useAnchorProps = (bindings: LinkBindings) => {
if (useNavigationDisabled()) {
return undefined;
}
return {
href: bindings.href,
onClick: bindings.onClick,
rel: bindings.rel,
target: bindings.target,
};
};
// a block `<a>` wrapping arbitrary children with no styling of its own — pass `className` for layout.
const BlockAnchor = ({
bindings,
children,
label,
...rest
}: { bindings: LinkBindings } & BlockAnchorProps) => (
<a {...rest} {...useAnchorProps(bindings)} aria-label={label}>
{children}
</a>
);
/** Underline timing for an inline link: `hover` (default), `always`, or `none`. */
export type InlineLinkUnderline = 'always' | 'hover' | 'none';
type InlineAnchorProps = Pick<
TextProps,
'align' | 'color' | 'leading' | 'numberOfLines' | 'selectable' | 'size' | 'weight'
> &
Omit<ComponentPropsWithoutRef<'a'>, 'color' | 'href' | 'onClick' | 'rel' | 'style' | 'target'> & {
children: ReactNode;
/** Accessible name; becomes the anchor's `aria-label`. */
label?: string;
/** Forwarded to the `<a>` so the link can back a headless trigger (e.g. a Base UI tooltip). */
ref?: Ref<HTMLAnchorElement>;
/** Underline timing; defaults to `hover`. */
underline?: InlineLinkUnderline;
};
// resolves the className + inline-var style shared by inline elements styled through the text recipe with the
// inline-link treatment (an `<a>` or a `<button>`).
const inlineTextStyle = ({
align,
className,
color,
leading,
numberOfLines,
selectable,
size,
underline,
weight,
}: Pick<
InlineAnchorProps,
| 'align'
| 'className'
| 'color'
| 'leading'
| 'numberOfLines'
| 'selectable'
| 'size'
| 'underline'
| 'weight'
>) => {
const clamped = numberOfLines != null;
const singleLine = numberOfLines === 1;
return {
className: clsx(
textStyles.text({ align, color, leading, size, weight }),
styles.inlineLink({ underline }),
clamped && (singleLine ? textStyles.clampSingleLine : textStyles.clampMultiLine),
selectable === true && textStyles.userSelect.text,
selectable === false && textStyles.userSelect.none,
className,
),
style:
clamped && !singleLine
? assignInlineVars({ [textStyles.lineClampVar]: String(numberOfLines) })
: undefined,
};
};
// an inline text `<a>` styled through the text recipe, defaulting to the primary color with an underline on
// hover/focus.
const InlineAnchor = ({
align,
bindings,
children,
className,
color = 'textLink',
label,
leading,
numberOfLines,
selectable,
size,
underline = 'hover',
weight,
...rest
}: { bindings: LinkBindings } & InlineAnchorProps) => {
const styled = inlineTextStyle({
align,
className,
color,
leading,
numberOfLines,
selectable,
size,
underline,
weight,
});
return (
<a
{...rest}
{...useAnchorProps(bindings)}
aria-label={label}
className={styled.className}
style={styled.style}
>
{children}
</a>
);
};
type ButtonAnchorProps = Omit<ButtonProps, 'nativeButton' | 'onClick' | 'render'>;
// a {@link Button} that renders an `<a>`, so it keeps real anchor semantics (middle/cmd-click, copy link
// address) while looking and laying out like a button.
const ButtonAnchor = ({ bindings, children, ...rest }: { bindings: LinkBindings } & ButtonAnchorProps) => {
return (
<Button
{...rest}
nativeButton={false}
onClick={bindings.onClick}
render={<a href={bindings.href} rel={bindings.rel} target={bindings.target} />}
>
{children}
</Button>
);
};
// #endregion
// #region internal links — `to` is an in-app route path
type InternalNavProps = {
/** How the link navigates when pressed. */
action?: LinkAction;
onPress?: LinkOnPress;
/** The in-app destination, e.g. `{ name: 'Profile', actor }`. */
to: RouteTarget;
};
export type LinkProps = InternalNavProps & BlockAnchorProps;
export type InlineLinkTextProps = InternalNavProps & InlineAnchorProps;
export type LinkButtonProps = InternalNavProps & ButtonAnchorProps;
/**
* a block link to an in-app route that navigates using the router on click, falling back to browser-native
* behavior on modified clicks.
*
* @param onPress callback to intercept the navigation; return false to cancel it.
*/
export const Link = ({ action, onPress, to, ...rest }: LinkProps) => {
const bindings = useInternalLink({ action, onPress, to });
return <BlockAnchor bindings={bindings} {...rest} />;
};
/** A web-native inline text link to an in-app route, styled through the text recipe. */
export const InlineLinkText = ({ action, onPress, to, ...rest }: InlineLinkTextProps) => {
const bindings = useInternalLink({ action, onPress, to });
return <InlineAnchor bindings={bindings} {...rest} />;
};
/** A web-native in-app-route link styled as a {@link Button}; renders an `<a>` so it keeps anchor semantics. */
export const LinkButton = ({ action, onPress, to, ...rest }: LinkButtonProps) => {
const bindings = useInternalLink({ action, onPress, to });
return <ButtonAnchor bindings={bindings} {...rest} />;
};
// #endregion
// #region external links — `href` is a raw URL
type ExternalNavProps = {
/** How the link navigates when an `href` that resolves in-app is pressed. */
action?: LinkAction;
/** A raw URL. A known client's URL is collapsed to an in-app path; anything else opens in a new tab. */
href: string;
onPress?: LinkOnPress;
};
export type ExternalLinkProps = ExternalNavProps & BlockAnchorProps;
export type ExternalInlineLinkTextProps = ExternalNavProps & InlineAnchorProps;
export type ExternalLinkButtonProps = ExternalNavProps & ButtonAnchorProps;
/** opens a raw URL in a new tab, or navigates internally if it resolves to an in-app route. */
export const ExternalLink = ({ action, href, onPress, ...rest }: ExternalLinkProps) => {
const bindings = useExternalLink({ action, href, onPress });
return <BlockAnchor bindings={bindings} {...rest} />;
};
/** A web-native inline text link to a raw URL, behaving like {@link ExternalLink}. */
export const ExternalInlineLinkText = ({ action, href, onPress, ...rest }: ExternalInlineLinkTextProps) => {
const bindings = useExternalLink({ action, href, onPress });
return <InlineAnchor bindings={bindings} {...rest} />;
};
/** A web-native raw-URL link styled as a {@link Button}; renders an `<a>` so it keeps anchor semantics. */
export const ExternalLinkButton = ({ action, href, onPress, ...rest }: ExternalLinkButtonProps) => {
const bindings = useExternalLink({ action, href, onPress });
return <ButtonAnchor bindings={bindings} {...rest} />;
};
// #endregion
// #region content links — `href` is a raw URL whose visible text is untrusted
export type ContentLinkTextProps = ExternalNavProps & InlineAnchorProps;
/**
* an inline text link for untrusted content. behaves like {@link ExternalInlineLinkText}, but verifies
* attacker-controlled children against the destination URL and opens a link-warning dialog on mismatch.
*/
export const ContentLinkText = ({ action, href, onPress, ...rest }: ContentLinkTextProps) => {
const bindings = useContentLink({
action,
displayText: typeof rest.children === 'string' ? rest.children : '',
href,
onPress,
});
return <InlineAnchor bindings={bindings} {...rest} />;
};
// #endregion
// #region inline button — an in-place action styled like inline link text
export type InlineButtonProps = Pick<TextProps, 'align' | 'color' | 'leading' | 'size' | 'weight'> &
Omit<ComponentPropsWithoutRef<'button'>, 'color' | 'style'> & {
children: ReactNode;
/** Accessible name; becomes the button's `aria-label`. */
label?: string;
/** Forwarded to the `<button>` so it can back a headless trigger (e.g. a Base UI dialog). */
ref?: Ref<HTMLButtonElement>;
/** Underline timing; defaults to `hover`. */
underline?: InlineLinkUnderline;
};
/**
* a web-native `<button>` styled identically to {@link InlineLinkText}, for in-place actions that sit inline
* in text flow (e.g. an "add feeds" action inside a sentence). use this instead of a dummy-target link for
* actions that do not navigate.
*/
export const InlineButton = ({
align,
children,
className,
color = 'textLink',
label,
leading,
size,
type = 'button',
underline = 'hover',
weight,
...rest
}: InlineButtonProps) => {
const styled = inlineTextStyle({ align, className, color, leading, size, underline, weight });
return (
<button {...rest} aria-label={label} className={styled.className} style={styled.style} type={type}>
{children}
</button>
);
};
// #endregion