From c858fc77ef194be0217fd98eae84efd261dba798 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 9 Oct 2025 16:31:13 +0200 Subject: [PATCH] Fixes handled link formatting (#36410) Co-authored-by: Claire --- .../status/handled_link.stories.tsx | 32 ++++++++++- .../components/status/handled_link.tsx | 56 +++++++++---------- .../mastodon/components/status_content.jsx | 6 +- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/app/javascript/mastodon/components/status/handled_link.stories.tsx b/app/javascript/mastodon/components/status/handled_link.stories.tsx index 71bf8eee6..e34383370 100644 --- a/app/javascript/mastodon/components/status/handled_link.stories.tsx +++ b/app/javascript/mastodon/components/status/handled_link.stories.tsx @@ -1,19 +1,24 @@ 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'; -type HandledLinkStoryProps = Pick & { +type HandledLinkStoryProps = Pick< + HandledLinkProps, + 'href' | 'text' | 'prevText' +> & { mentionAccount: 'local' | 'remote' | 'none'; + hashtagAccount: boolean; }; const meta = { title: 'Components/Status/HandledLink', - render({ mentionAccount, ...args }) { + render({ mentionAccount, hashtagAccount, ...args }) { let mention: HandledLinkProps['mention'] | undefined; if (mentionAccount === 'local') { mention = { id: '1', acct: 'testuser' }; @@ -22,7 +27,13 @@ const meta = { } return ( <> - + + {args.text} + @@ -32,6 +43,7 @@ const meta = { href: 'https://example.com/path/subpath?query=1#hash', text: 'https://example.com', mentionAccount: 'none', + hashtagAccount: false, }, argTypes: { mentionAccount: { @@ -40,6 +52,13 @@ const meta = { defaultValue: 'none', }, }, + parameters: { + state: { + accounts: { + '1': accountFactoryState({ id: '1', acct: 'hashtaguser' }), + }, + }, + }, } satisfies Meta; export default meta; @@ -48,9 +67,16 @@ type Story = StoryObj; export const Default: Story = {}; +export const Simple: Story = { + args: { + href: 'https://example.com/test', + }, +}; + export const Hashtag: Story = { args: { text: '#example', + hashtagAccount: true, }, }; diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index 0f486b33e..3c8973992 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -10,6 +10,7 @@ import type { OnElementHandler } from '@/mastodon/utils/html'; export interface HandledLinkProps { href: string; text: string; + prevText?: string; hashtagAccountId?: string; mention?: Pick; } @@ -17,13 +18,15 @@ export interface HandledLinkProps { export const HandledLink: FC> = ({ href, text, + prevText, hashtagAccountId, mention, className, + children, ...props }) => { // Handle hashtags - if (text.startsWith('#')) { + if (text.startsWith('#') || prevText?.endsWith('#')) { const hashtag = text.slice(1).trim(); return ( > = ({ rel='tag' data-menu-hashtag={hashtagAccountId} > - #{hashtag} + {children} ); - } else if (text.startsWith('@') && mention) { + } else if ((text.startsWith('@') || prevText?.endsWith('@')) && mention) { // Handle mentions return ( > = ({ title={`@${mention.acct}`} data-hover-card-account={mention.id} > - @{text.slice(1).trim()} + {children} ); } - // Non-absolute paths treated as internal links. + // Non-absolute paths treated as internal links. This shouldn't happen, but just in case. if (href.startsWith('/')) { return ( - {text} + {children} ); } - try { - const url = new URL(href); - const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash. - return ( - - {url.protocol + '//'} - {`${url.hostname}/${first ?? ''}`} - {'/' + rest.join('/')} - - ); - } catch { - return text; - } + return ( + + {children} + + ); }; export const useElementHandledLink = ({ @@ -89,7 +84,7 @@ export const useElementHandledLink = ({ hrefToMention?: (href: string) => ApiMentionJSON | undefined; } = {}) => { const onElement = useCallback( - (element, { key, ...props }) => { + (element, { key, ...props }, children) => { if (element instanceof HTMLAnchorElement) { const mention = hrefToMention?.(element.href); return ( @@ -98,9 +93,12 @@ export const useElementHandledLink = ({ 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} + ); } return undefined; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 54579a113..14779ce3a 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -204,7 +204,7 @@ class StatusContent extends PureComponent { this.node = c; }; - handleElement = (element, { key, ...props }) => { + handleElement = (element, { key, ...props }, children) => { if (element instanceof HTMLAnchorElement) { const mention = this.props.status.get('mentions').find(item => element.href === item.get('url')); return ( @@ -215,7 +215,9 @@ class StatusContent extends PureComponent { hashtagAccountId={this.props.status.getIn(['account', 'id'])} mention={mention?.toJSON()} key={key} - /> + > + {children} + ); } else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) { return null;