Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion frontend/src/v2/__tests__/V2MessageRow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2MessageRow from '../components/V2MessageRow';
import V2MessageRow, { linkifyBareUrls, messageMarkdownComponents } from '../components/V2MessageRow';

jest.mock('../../context/AuthContext', () => ({
useAuth: () => ({ currentUser: { username: 'viewer' } }),
Expand All @@ -14,6 +14,24 @@ describe('V2MessageRow', () => {
process.env.REACT_APP_API_URL = previousApiUrl;
});

it('links a complete bare URL, keeps trailing punctuation outside, and leaves markdown links alone', () => {
expect(linkifyBareUrls('Full notes: https://docs.example.com/launch/guide. Then [open the pod](https://commonly.me/v2/pods/abc).'))
.toBe('Full notes: [https://docs.example.com/launch/guide](https://docs.example.com/launch/guide). Then [open the pod](https://commonly.me/v2/pods/abc).');
expect(linkifyBareUrls('See https://commonly.me/v2/pods/abc, then https://mail.example.com/a:b.'))
.toBe('See [https://commonly.me/v2/pods/abc](https://commonly.me/v2/pods/abc), then [https://mail.example.com/a:b](https://mail.example.com/a:b).');
});

it('does not pass markdown parser nodes to link or code block DOM elements', () => {
const { container } = render(
<MemoryRouter>
{React.createElement(messageMarkdownComponents.a, { href: 'https://docs.example.com', node: {} }, 'docs')}
{React.createElement(messageMarkdownComponents.pre, { node: {} }, <code>echo hello</code>)}
</MemoryRouter>,
);
expect(container.querySelector('a')).not.toHaveAttribute('node');
expect(container.querySelector('pre')).not.toHaveAttribute('node');
});

it('keeps the runtime beside the agent name before its timestamp', () => {
render(
<MemoryRouter>
Expand Down
18 changes: 11 additions & 7 deletions frontend/src/v2/components/V2MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ import { messageImageUrl } from '../utils/messageImageUrl';
// match in `split()` output so we can render text + pill segments in order.
const MENTION_RE = /(@[a-zA-Z0-9][a-zA-Z0-9_-]*)/g;

// Markdown does not link raw URLs. Keep terminal sentence punctuation outside
// the link while consuming the full dotted host and path.
export const linkifyBareUrls = (content: string): string => content.replace(
/(?<![(<[])(https?:\/\/[^\s<>"]+[^\s<>".,!?;:])(?=[\s.,!?;:]|$)/g,
'[$1]($1)',
);

// Walk a React children tree and replace bare `@name` text segments with a
// styled `<span>` pill. Recurses into arrays + cloned elements so mentions
// inside `<strong>`, `<em>`, list items, etc. still render correctly. Code
Expand Down Expand Up @@ -69,9 +76,9 @@ export const MessageMarkdownImage = ({ src, alt }: React.ImgHTMLAttributes<HTMLI
return <span>{alt || src}</span>;
};

const messageMarkdownComponents = {
export const messageMarkdownComponents = {
img: MessageMarkdownImage,
a: ({ children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
a: ({ children, node: _node, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { node?: unknown }) => (
<a {...props} target="_blank" rel="noopener noreferrer">{renderWithMentions(children)}</a>
),
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
Expand All @@ -84,7 +91,7 @@ const messageMarkdownComponents = {
// `<pre>`. Both fall through to v2.css selectors `.v2-msg__content code`
// and `.v2-msg__content pre`. Mentions inside code are NOT transformed.
// Fenced code past six lines collapses (direction C).
pre: (props: React.HTMLAttributes<HTMLPreElement>) => <CollapsiblePre {...props} />,
pre: ({ node: _node, ...props }: React.HTMLAttributes<HTMLPreElement> & { node?: unknown }) => <CollapsiblePre {...props} />,
};

interface V2MessageRowProps {
Expand Down Expand Up @@ -522,10 +529,7 @@ const V2MessageRow: React.FC<V2MessageRowProps> = ({ message, sourceMessageId, d
// syntax. Without this, agents posting raw URLs render as plain text and
// the user can't click them.
if (stripped && !externalBareImage) {
stripped = stripped.replace(
/(?<![(<[])(https?:\/\/[^\s<>"]+?[^\s<>".,!?;:])(?=[\s.,!?;:]|$)/g,
'[$1]($1)',
);
stripped = linkifyBareUrls(stripped);
}

// Highlight messages that @-mention the current user. Word-boundary so
Expand Down
Loading