Files
mastodon/app/javascript/flavours/glitch/components/status/handled_link.tsx
2025-12-08 16:39:17 +01:00

241 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef } from 'react';
import type { ComponentProps, FC } from 'react';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
import { useAppSelector } from '@/flavours/glitch/store';
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
export interface HandledLinkProps {
href: string;
text: string;
prevText?: string;
hashtagAccountId?: string;
mention?: Pick<ApiMentionJSON, 'id' | 'acct' | 'username'>;
}
const textMatchesTarget = (text: string, origin: string, host: string) => {
return (
text === origin ||
text === host ||
text.startsWith(origin + '/') ||
text.startsWith(host + '/') ||
'www.' + text === host ||
('www.' + text).startsWith(host + '/')
);
};
export const isLinkMisleading = (link: HTMLAnchorElement) => {
const linkTextParts: string[] = [];
// Reconstruct visible text, as we do not have much control over how links
// from remote software look, and we can't rely on `innerText` because the
// `invisible` class does not set `display` to `none`.
const walk = (node: Node) => {
if (node instanceof Text) {
linkTextParts.push(node.textContent);
} else if (node instanceof HTMLElement) {
if (node.classList.contains('invisible')) return;
for (const child of node.childNodes) {
walk(child);
}
}
};
walk(link);
const linkText = linkTextParts.join('');
const targetURL = new URL(link.href);
if (targetURL.protocol === 'magnet:') {
return !linkText.startsWith('magnet:');
}
if (targetURL.protocol === 'xmpp:') {
return !(
linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href
);
}
// The following may not work with international domain names
if (
textMatchesTarget(linkText, targetURL.origin, targetURL.host) ||
textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)
) {
return false;
}
// The link hasn't been recognized, maybe it features an international domain name
const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC');
const host = targetURL.host.replace(targetURL.hostname, hostname);
const origin = targetURL.origin.replace(targetURL.host, host);
const text = linkText.normalize('NFKC');
return !(
textMatchesTarget(text, origin, host) ||
textMatchesTarget(text.toLowerCase(), origin, host)
);
};
export const tagMisleadingLink = (link: HTMLAnchorElement) => {
try {
if (isLinkMisleading(link)) {
const url = new URL(link.href);
const tag = document.createElement('span');
tag.classList.add('link-origin-tag');
switch (url.protocol) {
case 'xmpp:':
tag.textContent = `[${url.href}]`;
break;
case 'magnet:':
tag.textContent = '(magnet)';
break;
default:
tag.textContent = `[${url.host}]`;
}
link.insertAdjacentText('beforeend', ' ');
link.insertAdjacentElement('beforeend', tag);
}
} catch (e) {
// The URL is invalid, remove the href just to be safe
if (e instanceof TypeError) link.removeAttribute('href');
}
};
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
href,
text,
prevText,
hashtagAccountId,
mention,
className,
children,
...props
}) => {
const rewriteMentions = useAppSelector(
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
(state) => state.local_settings.get('rewrite_mentions', 'no') as string,
);
const tagLinks = useAppSelector(
(state) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
state.local_settings.get('tag_misleading_links', false) as string,
);
const linkRef = useRef<HTMLAnchorElement>(null);
useEffect(() => {
if (tagLinks && linkRef.current) tagMisleadingLink(linkRef.current);
}, [tagLinks]);
// Handle hashtags
if (
(text.startsWith('#') ||
prevText?.endsWith('#') ||
text.startsWith('') ||
prevText?.endsWith('')) &&
!text.includes('%')
) {
const hashtag = text.slice(1).trim();
return (
<Link
className={classNames('mention hashtag', className)}
to={`/tags/${encodeURIComponent(hashtag)}`}
rel='tag'
data-menu-hashtag={hashtagAccountId}
>
{children}
</Link>
);
} else if (mention) {
// glitch-soc feature to rewrite mentions
if (rewriteMentions !== 'no') {
return (
<Link
className={classNames('mention', className)}
to={`/@${mention.acct}`}
title={`@${mention.acct}`}
data-hover-card-account={mention.id}
>
@
<span>
{rewriteMentions === 'acct' ? mention.acct : mention.username}
</span>
</Link>
);
}
// Handle mentions
return (
<Link
className={classNames('mention', className)}
to={`/@${mention.acct}`}
title={`@${mention.acct}`}
data-hover-card-account={mention.id}
>
{children}
</Link>
);
}
// Non-absolute paths treated as internal links. This shouldn't happen, but just in case.
if (href.startsWith('/')) {
return (
<Link className={classNames('unhandled-link', className)} to={href}>
{children}
</Link>
);
}
return (
<a
{...props}
href={href}
title={href}
className={classNames('unhandled-link', className)}
target='_blank'
rel='noopener'
translate='no'
ref={linkRef}
>
{children}
</a>
);
};
export const useElementHandledLink = ({
hashtagAccountId,
hrefToMention,
}: {
hashtagAccountId?: string;
hrefToMention?: (href: string) => ApiMentionJSON | undefined;
} = {}) => {
const onElement = useCallback<OnElementHandler>(
(element, { key, ...props }, children) => {
if (element instanceof HTMLAnchorElement) {
const mention = hrefToMention?.(element.href);
return (
<HandledLink
{...props}
key={key as string} // React requires keys to not be part of spread props.
href={element.href}
text={element.innerText}
prevText={element.previousSibling?.textContent ?? undefined}
hashtagAccountId={hashtagAccountId}
mention={mention}
>
{children}
</HandledLink>
);
}
return undefined;
},
[hashtagAccountId, hrefToMention],
);
return { onElement };
};