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 {