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; } 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> = ({ 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(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 ( {children} ); } else if (mention) { // glitch-soc feature to rewrite mentions if (rewriteMentions !== 'no') { return ( @ {rewriteMentions === 'acct' ? mention.acct : mention.username} ); } // Handle mentions return ( {children} ); } // Non-absolute paths treated as internal links. This shouldn't happen, but just in case. if (href.startsWith('/')) { return ( {children} ); } return ( {children} ); }; export const useElementHandledLink = ({ hashtagAccountId, hrefToMention, }: { hashtagAccountId?: string; hrefToMention?: (href: string) => ApiMentionJSON | undefined; } = {}) => { const onElement = useCallback( (element, { key, ...props }, children) => { if (element instanceof HTMLAnchorElement) { const mention = hrefToMention?.(element.href); return ( {children} ); } return undefined; }, [hashtagAccountId, hrefToMention], ); return { onElement }; };