Skip to content

Commit 5eca064

Browse files
CassioMGJakeUrbanclaude
authored
discovery: support hyperlinks in protocol descriptions (#994) (#997)
* discovery: support hyperlinks in protocol descriptions Adds a LinkedText component that turns markdown-style [text](https://...) links and bare https:// URLs in a protocol's description into tappable links via the existing Text `url` prop, restricted to https to match the HTTPS-only convention already used for protocol URLs elsewhere (helpers/protocols.ts). Newlines already render correctly in React Native, so no change was needed there. * discovery: fix link parsing for balanced parentheses in URLs Ports the fix from stellar/freighter PR #2985 (Copilot review feedback): the regex-based matcher excluded all parentheses from a URL's character class, truncating URLs that legitimately contain them (e.g. Wikipedia-style https://.../Function_(mathematics)), and for the markdown form the first ")" inside the URL was mistaken for the link's closing paren. Replaced it with a small scanner that tracks paren depth so balanced parens stay part of the URL, and only an unmatched ")" ends it. Added regression tests for both the bare-URL and markdown-link forms. * discovery: fix link parsing bugs and address review feedback Addresses review feedback on #994: - Copilot: __tests__/helpers/linkedText.test.ts imported "helpers/linkedText", which jest.config.js's moduleNameMapper remaps to __mocks__/helpers/linkedText - a file that doesn't exist, so the suite failed to resolve the module before any test ran. Import the real module via a relative path instead. - Copilot: LinkedText had no component-level test exercising the actual tappable behavior (pressing a parsed segment opens the right URL) or its accessibility semantics. - Copilot: bare URLs wrapped in quotes (e.g. "https://example.com/x") absorbed the closing quote into the tappable URL. - aristidesstaffieri: a stray, unmatched "[" before a real markdown link could swallow the real link into its own (invalid) label (matching the same fix applied on the extension PR, stellar/freighter#2985). Replaced the backward-scanning markdown-opener regex with a forward scanner that looks for the next "[" or bare "https://" (whichever comes first) and only commits to a markdown link once a complete, valid [label](https://...) is found; this also fixes a related Copilot finding on the extension PR where a label containing its own https:// URL was mishandled. Tightened the URL boundary characters to also stop at quotes. Added a LinkedText component test (renders both link forms, presses each, asserts the in-app browser is opened with the correct URL) and accessibilityRole="link" on tappable segments, matching the existing pattern in BalancesList.tsx. * discovery: reject malformed URLs, keep brackets in markdown destinations Addresses further Copilot feedback on #994: - A candidate was only checked against the "https://" text prefix, so malformed values like bare "https://" or "https://?query" (no host) were still emitted as tappable links. Validate every candidate with `new URL(...)` and require the "https:" protocol before treating it as a link; rejected candidates are left as plain text (the original substring, not `url.href`, is still used for display/navigation, since the WHATWG URL parser normalizes some inputs like adding a trailing slash to a bare origin). - The URL boundary excluded square brackets universally, which broke markdown-link destinations containing the common `?tag[]=value` query syntax: the parse failed and the same target was then emitted as a truncated bare link pointing at the wrong address. Brackets are now only excluded from a *bare* URL's boundary (to avoid ambiguity with an immediately following markdown link) - a markdown destination's balanced closing paren is enough to bound it on its own. - Added the same eslint-disable-next-line react/no-array-index-key suppression to the linked-segment branch in LinkedText.tsx that the plain-text branch already had, since the AirBnB rule also flags indexes used inside template literals. Added regression tests for both parser fixes, matching stellar/freighter#2985. --------- Co-authored-by: Jake Urban <10968980+JakeUrban@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2bed4c0 commit 5eca064

7 files changed

Lines changed: 474 additions & 2 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { fireEvent, act } from "@testing-library/react-native";
2+
import { LinkedText } from "components/LinkedText";
3+
import { renderWithProviders } from "helpers/testUtils";
4+
import React from "react";
5+
import { Linking } from "react-native";
6+
7+
describe("LinkedText", () => {
8+
it("renders plain text with no links untouched", () => {
9+
const { getByText } = renderWithProviders(
10+
<LinkedText md>just plain text</LinkedText>,
11+
);
12+
expect(getByText("just plain text")).toBeTruthy();
13+
});
14+
15+
it("opens the target URL when a markdown-style link is pressed", async () => {
16+
const { getByText } = renderWithProviders(
17+
<LinkedText md>
18+
{"See the [docs](https://example.com/docs) now."}
19+
</LinkedText>,
20+
);
21+
22+
const link = getByText("docs");
23+
expect(link.props.accessibilityRole).toBe("link");
24+
25+
// eslint-disable-next-line @typescript-eslint/require-await
26+
await act(async () => {
27+
fireEvent.press(link);
28+
});
29+
30+
expect(Linking.openURL).toHaveBeenCalledWith("https://example.com/docs");
31+
});
32+
33+
it("opens the URL when a bare https:// link is pressed", async () => {
34+
const { getByText } = renderWithProviders(
35+
<LinkedText md>{"Visit https://example.com/x today"}</LinkedText>,
36+
);
37+
38+
const link = getByText("https://example.com/x");
39+
expect(link.props.accessibilityRole).toBe("link");
40+
41+
// eslint-disable-next-line @typescript-eslint/require-await
42+
await act(async () => {
43+
fireEvent.press(link);
44+
});
45+
46+
expect(Linking.openURL).toHaveBeenCalledWith("https://example.com/x");
47+
});
48+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// "helpers/*" is remapped by jest.config.js's moduleNameMapper to
2+
// __mocks__/helpers/*, which has no entry for linkedText - import the real
3+
// module directly to get the actual implementation under test.
4+
import { parseLinkedText } from "../../src/helpers/linkedText";
5+
6+
describe("parseLinkedText", () => {
7+
it("returns a single plain-text segment when there are no links", () => {
8+
expect(parseLinkedText("just plain text")).toEqual([
9+
{ text: "just plain text" },
10+
]);
11+
});
12+
13+
it("parses a markdown-style link", () => {
14+
expect(
15+
parseLinkedText(
16+
"See the [incident update](https://example.com/x) now.",
17+
),
18+
).toEqual([
19+
{ text: "See the " },
20+
{ text: "incident update", url: "https://example.com/x" },
21+
{ text: " now." },
22+
]);
23+
});
24+
25+
it("linkifies a bare URL", () => {
26+
expect(parseLinkedText("Visit https://example.com/x today")).toEqual([
27+
{ text: "Visit " },
28+
{ text: "https://example.com/x", url: "https://example.com/x" },
29+
{ text: " today" },
30+
]);
31+
});
32+
33+
it("strips trailing punctuation from a bare URL", () => {
34+
expect(parseLinkedText("See https://example.com/x.")).toEqual([
35+
{ text: "See " },
36+
{ text: "https://example.com/x", url: "https://example.com/x" },
37+
{ text: "." },
38+
]);
39+
});
40+
41+
it("handles multiple links in the same string", () => {
42+
expect(
43+
parseLinkedText(
44+
"First https://a.com then [second](https://b.com) link.",
45+
),
46+
).toEqual([
47+
{ text: "First " },
48+
{ text: "https://a.com", url: "https://a.com" },
49+
{ text: " then " },
50+
{ text: "second", url: "https://b.com" },
51+
{ text: " link." },
52+
]);
53+
});
54+
55+
it("does not linkify non-https schemes", () => {
56+
expect(parseLinkedText("Run javascript:alert(1) please")).toEqual([
57+
{ text: "Run javascript:alert(1) please" },
58+
]);
59+
});
60+
61+
it("does not linkify a bare http (non-https) URL", () => {
62+
expect(parseLinkedText("Visit http://example.com/x today")).toEqual([
63+
{ text: "Visit http://example.com/x today" },
64+
]);
65+
});
66+
67+
it("keeps balanced parentheses inside a bare URL", () => {
68+
expect(
69+
parseLinkedText(
70+
"See https://en.wikipedia.org/wiki/Function_(mathematics) for details.",
71+
),
72+
).toEqual([
73+
{ text: "See " },
74+
{
75+
text: "https://en.wikipedia.org/wiki/Function_(mathematics)",
76+
url: "https://en.wikipedia.org/wiki/Function_(mathematics)",
77+
},
78+
{ text: " for details." },
79+
]);
80+
});
81+
82+
it("keeps balanced parentheses inside a markdown-style URL", () => {
83+
expect(
84+
parseLinkedText(
85+
"See [Function](https://en.wikipedia.org/wiki/Function_(mathematics)) for details.",
86+
),
87+
).toEqual([
88+
{ text: "See " },
89+
{
90+
text: "Function",
91+
url: "https://en.wikipedia.org/wiki/Function_(mathematics)",
92+
},
93+
{ text: " for details." },
94+
]);
95+
});
96+
97+
it("does not let a stray unmatched bracket swallow the real link", () => {
98+
expect(
99+
parseLinkedText("Rates [APY vary. See the [docs](https://b.com)."),
100+
).toEqual([
101+
{ text: "Rates [APY vary. See the " },
102+
{ text: "docs", url: "https://b.com" },
103+
{ text: "." },
104+
]);
105+
});
106+
107+
it("does not double-linkify a markdown label that is itself a URL", () => {
108+
expect(
109+
parseLinkedText("[https://label.example](https://target.example)"),
110+
).toEqual([
111+
{ text: "https://label.example", url: "https://target.example" },
112+
]);
113+
});
114+
115+
it("does not absorb a quote that closes a quoted bare URL", () => {
116+
expect(parseLinkedText('See "https://example.com/x" now.')).toEqual([
117+
{ text: 'See "' },
118+
{ text: "https://example.com/x", url: "https://example.com/x" },
119+
{ text: '" now.' },
120+
]);
121+
});
122+
123+
it("does not linkify a malformed bare URL with no host", () => {
124+
expect(parseLinkedText("See https:// for syntax")).toEqual([
125+
{ text: "See https:// for syntax" },
126+
]);
127+
expect(parseLinkedText("See https://?query for syntax")).toEqual([
128+
{ text: "See https://?query for syntax" },
129+
]);
130+
});
131+
132+
it("does not linkify a malformed markdown URL with no host", () => {
133+
expect(parseLinkedText("[bad](https://)")).toEqual([
134+
{ text: "[bad](https://)" },
135+
]);
136+
});
137+
138+
it("keeps square brackets inside a markdown destination", () => {
139+
expect(
140+
parseLinkedText(
141+
"Search [tags](https://example.com/search?tag[]=security) here.",
142+
),
143+
).toEqual([
144+
{ text: "Search " },
145+
{ text: "tags", url: "https://example.com/search?tag[]=security" },
146+
{ text: " here." },
147+
]);
148+
});
149+
});

src/components/LinkedText.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Text, TextProps } from "components/sds/Typography";
2+
import { THEME } from "config/theme";
3+
import { parseLinkedText } from "helpers/linkedText";
4+
import React from "react";
5+
6+
interface LinkedTextProps extends Omit<TextProps, "children" | "url"> {
7+
children: string;
8+
}
9+
10+
/**
11+
* Renders text while turning markdown-style `[text](https://...)` links and
12+
* bare `https://` URLs into tappable links, opened via the existing `Text`
13+
* `url` prop (in-app browser). Everything else renders as plain text - no
14+
* other markdown/HTML is parsed, so this is safe to use directly on
15+
* untrusted/external strings (e.g. API responses).
16+
*
17+
* Drop-in replacement for `<Text>` wherever the text content may contain a
18+
* link, e.g. `<LinkedText md>{protocol.description}</LinkedText>`.
19+
*/
20+
export const LinkedText: React.FC<LinkedTextProps> = ({
21+
children,
22+
...textProps
23+
}) => (
24+
<Text {...textProps}>
25+
{parseLinkedText(children).map((segment, index) =>
26+
segment.url ? (
27+
// eslint-disable-next-line react/no-array-index-key
28+
<Text
29+
key={`${index}-${segment.url}`}
30+
{...textProps}
31+
color={THEME.colors.primary}
32+
url={segment.url}
33+
accessibilityRole="link"
34+
>
35+
{segment.text}
36+
</Text>
37+
) : (
38+
// eslint-disable-next-line react/no-array-index-key
39+
<React.Fragment key={`${index}-text`}>{segment.text}</React.Fragment>
40+
),
41+
)}
42+
</Text>
43+
);

src/components/screens/DiscoveryScreen/components/ProtocolDetailsBottomSheet.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { BottomSheetModal } from "@gorhom/bottom-sheet";
22
import BottomSheet from "components/BottomSheet";
3+
import { LinkedText } from "components/LinkedText";
34
import { App } from "components/sds/App";
45
import { Badge } from "components/sds/Badge";
56
import { Button } from "components/sds/Button";
@@ -15,6 +16,11 @@ interface ProtocolDetails {
1516
name: string;
1617
iconUrl: string;
1718
websiteUrl: string;
19+
/**
20+
* Rendered via LinkedText: supports markdown-style `[text](https://...)`
21+
* links and bare `https://` URLs, which render as tappable links. No
22+
* other markdown/HTML is parsed.
23+
*/
1824
description: string;
1925
tags: string[];
2026
}
@@ -96,7 +102,7 @@ const ProtocolDetailsBottomSheet: React.FC<ProtocolDetailsBottomSheetProps> =
96102
<Text sm secondary>
97103
{t("discovery.description")}
98104
</Text>
99-
<Text md>{protocol.description}</Text>
105+
<LinkedText md>{protocol.description}</LinkedText>
100106
</View>
101107
)}
102108
</View>

src/config/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,11 @@ export type CollectibleMetadata = {
429429
* Represents a discover protocol from the backend API
430430
*/
431431
export type DiscoverProtocol = {
432+
/**
433+
* Rendered via LinkedText (components/LinkedText): supports markdown-style
434+
* `[text](https://...)` links and bare `https://` URLs, which render as
435+
* tappable links. No other markdown/HTML is parsed.
436+
*/
432437
description: string;
433438
iconUrl: string;
434439
name: string;

0 commit comments

Comments
 (0)