Skip to content

Mobile: native iOS link context menu for inline markdown links (custom UITextView) #273

Description

@PotatoParser

Summary

Assistant markdown links in the mobile app currently get a custom JavaScript overlay for the long-press menu (mobile/src/components/chat/linkContextMenu.tsxLinkContextMenuHost) that approximates the native iOS link context menu. We should eventually replace this approximation with the genuine native iOS UIContextMenu (the blurred, system-rendered "Peek and Pop" menu with link preview), which requires rendering the assistant text in a real native UITextView.

This is tracked as a future/polish item — the current JS overlay works and ships in #262. This issue captures why the native menu isn't trivially available and what the real fix looks like, so we don't relitigate it.

Background: why we have a JS overlay instead of the native menu

The native iOS link menu is produced by UITextView (via UITextViewDelegate.textView(_:menuConfigurationFor:defaultMenu:), iOS 17+, on UITextItems backed by NSLinkAttributeName/UITextItemTag ranges). React Native's <Text> is not a UITextView — on the New Architecture it renders as RCTParagraphComponentView, which draws glyphs with CoreText. There is no UITextView in the tree, so there is nothing to host the per-link UIContextMenuInteraction. That is why the long-press menu had to be re-implemented as a JS overlay.

Why expo-router Link Preview / Link.Menu does not solve this

expo-router (we're on 56.2.8) ships a real native context-menu API — Link.Trigger / Link.Preview / Link.Menu / Link.MenuAction (docs: https://docs.expo.dev/router/reference/link-preview/). It is the genuine native UIContextMenu. But it is built for in-app route navigation, not inline external hyperlinks:

  • Preview is hard-disabled for external URLs. In node_modules/expo-router/build/link/LinkWithPreview.js the preview element is nulled when the href is external:
    const preview = useMemo(
      () => (shouldLinkExternally(String(rest.href)) || !previewElement ? null : previewElement),
      [previewElement, rest.href]
    );
    HrefPreview resolves the href through the router (store.getStateForHref) and renders the destination screen as the preview; an https:// URL has no route, so it falls to an "Invalid preview" placeholder.
  • The trigger is a native block-host view, not an inline text run. It composes for a standalone/block link (a row, a card, a button) but not for a hyperlink mid-paragraph that must wrap across lines like text.
  • Verified on the iOS 26.5 simulator (iPhone 17 Pro): rendering an inline <Link href="https://docs.anthropic.com"><Link.Trigger>…</Link.Trigger><Link.Menu>…</Link.Menu></Link> and long-pressing it produced no context menu — the press just navigated out to the external browser. The native menu does not attach for external-href links.

So expo-router's API is the right tool for navigation links, and the wrong tool for inline external markdown links.

Proposed fix (the real one)

Render assistant markdown (or at least link-bearing inline text) through a custom Fabric native component that wraps a UITextView:

  • Back link ranges with NSLinkAttributeName + UITextItemTag.
  • Implement textView(_:menuConfigurationFor:defaultMenu:) (iOS 17+) to return our actions (Open Link → default browser, Copy Link, Share…), getting the system blur + preview for free.
  • Implement textView(_:primaryActionFor:defaultAction:) for tap (→ in-app browser, to match current behavior).
  • Bridge the same scheme allowlist + host-sanitization currently in linkContextMenu.tsx so behavior/security parity is preserved.

Alternatives considered

  • bluesky-social/react-native-uitextview gives a real UITextView (selection/translation) but does not expose the link-item menu delegate — it would need extending to add menuConfigurationFor.
  • Block-level links via expo-router Link.Trigger+Link.Menu (e.g. a "Sources" chip list under a message) would get the native menu for free, at the cost of inline flow. Viable for a sources UI, not for inline body links.
  • Keep the JS overlay (current state). Acceptable; it just isn't the system menu (no system blur/preview, must track theme manually).

Acceptance criteria

  • Long-pressing an inline link in an assistant message shows the native iOS UIContextMenu (system blur + link preview), not the JS overlay.
  • Menu actions: Open Link (default browser), Copy Link, Share… — parity with today.
  • Tap still opens the in-app browser; scheme allowlist + host sanitization preserved.
  • Inline links wrap across lines like normal text; selection still works on assistant text.
  • Android path defined (keep JS overlay or a platform-appropriate native menu).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions