diff --git a/app/javascript/mastodon/components/status/handled_link.stories.tsx b/app/javascript/mastodon/components/status/handled_link.stories.tsx new file mode 100644 index 00000000000..a45e33626ae --- /dev/null +++ b/app/javascript/mastodon/components/status/handled_link.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { HashtagMenuController } from '@/mastodon/features/ui/components/hashtag_menu_controller'; +import { accountFactoryState } from '@/testing/factories'; + +import { HoverCardController } from '../hover_card_controller'; + +import type { HandledLinkProps } from './handled_link'; +import { HandledLink } from './handled_link'; + +const meta = { + title: 'Components/Status/HandledLink', + render(args) { + return ( + <> + + + + + ); + }, + args: { + href: 'https://example.com/path/subpath?query=1#hash', + text: 'https://example.com', + }, + parameters: { + state: { + accounts: { + '1': accountFactoryState(), + }, + }, + }, +} satisfies Meta>; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Hashtag: Story = { + args: { + text: '#example', + }, +}; + +export const Mention: Story = { + args: { + text: '@user', + }, +}; + +export const InternalLink: Story = { + args: { + href: '/about', + text: 'About', + }, +}; + +export const InvalidURL: Story = { + args: { + href: 'ht!tp://invalid-url', + text: 'ht!tp://invalid-url -- invalid!', + }, +}; diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index b5d97f66a97..c9e8dde3844 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -1,9 +1,8 @@ -import { useId } from 'react'; import type { ComponentProps, FC } from 'react'; import { Link } from 'react-router-dom'; -interface HandledLinkProps { +export interface HandledLinkProps { href: string; text: string; hashtagAccountId?: string; @@ -15,9 +14,9 @@ export const HandledLink: FC> = ({ text, hashtagAccountId, mentionAccountId, + key, ...props }) => { - const id = useId(); // Handle hashtags if (text.startsWith('#')) { const hashtag = text.slice(1).trim(); @@ -28,7 +27,7 @@ export const HandledLink: FC> = ({ to={`/tags/${hashtag}`} rel='tag' data-menu-hashtag={hashtagAccountId} - key={id} + key={key} > #{hashtag} @@ -39,20 +38,29 @@ export const HandledLink: FC> = ({ return ( @{mention} ); } + + // Non-absolute paths treated as internal links. if (href.startsWith('/')) { - return text; + return ( + + {text} + + ); } + try { const url = new URL(href); + const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash. return ( > = ({ target='_blank' rel='noreferrer noopener' translate='no' - key={id} + key={key} > - {url.protocol} - - {url.hostname + url.pathname.split('/').slice(0, 1).join('/')} - - - {url.pathname.split('/').slice(1).join('/') + url.search + url.hash} - + {url.protocol + '//'} + {`${url.hostname}/${first ?? ''}`} + {'/' + rest.join('/')} ); } catch {