Skip to content

feat: add native link pills - #920

Open
hryhoriiK97 wants to merge 2 commits into
mainfrom
feat/link-pills
Open

hryhoriiK97 wants to merge 2 commits into
mainfrom
feat/link-pills

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

What/Why?

Opt-in pill presentation for Markdown links on iOS and Android. A link whose markdownStyle.linkVariants entry sets pill is drawn as a rounded label with an optional icon; every other link is unchanged. Refs #860.

feat/link-pills is the integration branch for this feature. It lands on main through this PR; the work itself is reviewed in smaller PRs that target the branch:

CI runs here every time something is merged into the branch.

  • Style: pill: true uses the defaults; pill: { label, iconUri, borderRadius, paddingHorizontal, paddingVertical, borderWidth, borderColor, maxWidth } customizes it; omitted, false or null keeps an ordinary link. Colors, underline and font family stay on the link variant.
  • Per-link content: the new linkPillContent prop sets the label and icon of individual links by exact URL. It is content, not style, so markdownStyle can stay a stable object while it changes. Per-link content wins over the variant's pill.label / pill.iconUri, which win over the link text.
  • Icons: local files and bundled assets show on the first layout. http(s) sources load through the same pipeline as Markdown images, using imageRequestHeaders, and the pill keeps room for them while they load. A source that fails shows no icon and is retried after 30 seconds.
  • Layout: a pill wraps as one unit and truncates at the available width or maxWidth. It works in paragraphs, headings, lists, blockquotes and table cells.
  • Links that stay ordinary: a link whose text contains an image, inline math, a hard line break or a spoiler. A pill inside an unrevealed spoiler is hidden with the rest of the spoiler.
  • Text leaving the view: copy, Markdown/HTML/RTF export, accessibility labels and the iOS selection actions (Look Up, Translate, Share) receive the link's own text and URL.
  • Web and macOS: render ordinary links and ignore pill and linkPillContent.

API

<EnrichedMarkdownText
  markdown="Open [src/components/a/long/path/file.ts](https://example.com/files/a/long/path/file.ts)"
  markdownStyle={{
    linkVariants: {
      '^https://example\\.com/files/': {
        color: '#3730A3',
        backgroundColor: '#EEF2FF',
        pill: { iconUri: 'file_icon', borderRadius: 10, maxWidth: 180 },
      },
    },
  }}
  linkPillContent={{
    'https://example.com/files/a/long/path/file.ts': { label: 'file.ts' },
  }}
/>

Docs: docs/MENTIONS.md (pill section), docs/API_REFERENCE.md (linkPillContent), docs/WEB.md, docs/MACOS.md.

Implementation

  • iOS: a pill link is one attachment character (ENRMLinkPillAttachment), laid out and drawn by TextKit like images and inline math. The link's rendered text is kept on the attachment and put back by ENRMLinkPillText wherever text leaves the view. ENRMMarkdownTextView does the same for the system selection actions. This replaces the custom text storage and glyph substitution of the first version.
  • Android: LinkPillSpan is a ReplacementSpan over the original link text, which stays in the buffer. Widths are prepared per layout thread through a shared helper (prepareWidthAwareSpans), and a span change redraws the pill when its icon arrives or a spoiler over it is revealed.
  • Shared: pill is a nested object on the native link variant; linkPillContent is passed like imageRequestHeaders. Measurement caches on both platforms account for link variants and pill content.
  • Outside the feature: the Android ImageDownloader now reports a request OkHttp rejects as a failed download instead of throwing into the render. The example's iOS test target inherits only the pods' search paths, because linking the pods into the test bundle loaded every library class twice.
  • Packaging: android/src/test is excluded from the npm package (files in package.json). The android folder is published whole, so the unit tests shipped with it; a dry run of npm pack now lists no test files.

Testing

CI passed on the last head of #862 (60896773): RN lint, library build, Android and iOS builds, Android and iOS unit tests. The branch holds the same content.

  • Jest: link-pill-style.test.ts (style normalization, content normalization and equality).
  • Android: 38 Robolectric tests in LinkPillSpanTest.kt.
  • iOS: 19 XCTest cases in ENRMLinkPillAttachmentTests.mm, now registered in the example's test target and run by CI.
  • Manual: iOS simulator (iPhone 17, iOS 26.5) and an Android emulator, with a local demo screen that is not part of this PR. Checked rendering in every block type, remote icons arriving late, a failing icon, and spoiler reveal.

Not covered: drag-and-drop of a selection containing a pill on iOS, and Maestro E2E. After main was merged into the branch, the on-device check was repeated only for rendering on Android.

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

🤖 Generated with Claude Code

* feat: allow link variants to override font family

* fix: cleanup; docs: storybook

* feat: add opt-in native link variant pills

* fix: render iOS pill attachment only at its first source character

* fix: track pill glyph continuations without recursive layout queries

* perf: skip iOS pill glyph copies and range scans for ordinary text

* fix: address native link pill review findings

* refactor(link-pill): render pills as a native attachment and add per-link content

Rework the link pill presentation on top of the original implementation.

iOS: a pill link is now a single attachment character laid out and drawn by
TextKit, like images and inline math, instead of hidden link characters
substituted through a custom text storage. The link's own text is kept on the
attachment and put back wherever text leaves the view: copy, Markdown/HTML/RTF
export, accessibility, and the system selection actions.

Android: pills prepare their width per layout thread through a shared helper,
are set directly while rendering, and are redrawn through a span change when an
icon arrives or a spoiler over them is revealed.

Both platforms:
- `linkPillContent` prop: per-link label and icon keyed by exact URL
- `pill` is a nested object on the native link variant
- remote icons through the existing image pipeline, with bounded caches and a
  retry window for failed sources
- links holding an image, inline math, a hard line break or a spoiler stay
  ordinary links; a pill inside an unrevealed spoiler is hidden
- measurement caches account for link variants and pill content

Also makes the shared Android image downloader report a request OkHttp rejects
as a failed download instead of throwing into the render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(storybook): add link pill story

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(ios): run the link pill tests in the example test target

Add the pill attachment tests and the library header paths they import to
EnrichedMarkdownExampleTests, and let the target inherit only the pods' search
paths: linking the pods into the test bundle as well loaded every library class
twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: document link pill content and behavior

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(example): match the Podfile checksum to the test target change

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore: keep Android unit tests out of the npm package

The android folder is published whole, so android/src/test shipped with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Ernest <ernest.szlamczyk@swmansion.com>
Co-authored-by: Ernest Szlamczyk <127619251+eszlamczyk@users.noreply.github.com>
Co-authored-by: Gregory Moskaliuk <mosckalyuck@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…an attachment

TextKit places an attachment glyph at the bottom edge of its bounds, not on
the baseline. The marker drawer read the first glyph's location as the
baseline, so an item that starts with a link pill, an inline image or inline
math drew its bullet, number or checkbox too low by however far the
attachment hangs below the text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants