fix(app): render inline links via UITextView span on iOS (#1257)
AssistantMarkdownLink's native branch wrapped its children in a plain RN <Text>. On iOS that <Text> sits inside the paragraph's native UITextView and is not hoisted into a UITextViewChild, so link text (markdown links, bare URLs, and inline-code path links) was silently dropped — the same root cause as the bold/italic/strikethrough fix in this PR. Route the native link through MarkdownTextSpan so it composes as a UITextViewChild and stays visible + selectable. MarkdownTextSpan now forwards onPress/accessibilityRole across platforms: tappable on Android/web; on iOS react-native-uitextview nulls onPress on the root native view, so reliable tap-to-open is still tracked by #21 — but visible, selectable, copyable link text strictly beats an invisible one. Refs #1253 Co-authored-by: Geoffrey <geoffrey.marc@consulting-for.edenred.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Geoffrey
Claude Opus 4.8
parent
632c48fde3
commit
a3b0af9ac0
@@ -9,6 +9,7 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -57,16 +58,21 @@ export function AssistantMarkdownLink({
|
||||
);
|
||||
|
||||
if (isNative) {
|
||||
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
|
||||
// inside the paragraph's native UITextView, and a plain <Text> nested there
|
||||
// is not hoisted into a UITextViewChild, so its text is silently dropped
|
||||
// (the link disappears). The span composes correctly and stays selectable;
|
||||
// onPress is forwarded (reliable tap-to-open on iOS is tracked by #21).
|
||||
return (
|
||||
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||
<Text
|
||||
<MarkdownTextSpan
|
||||
accessibilityRole="link"
|
||||
dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined}
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</MarkdownTextSpan>
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
// Android's <Text selectable> enables per-text-node selection natively. Each
|
||||
// sibling Text is its own selection scope — drag can't span across siblings
|
||||
// (that requires a single UITextView ancestor and is iOS-only).
|
||||
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
|
||||
// (that requires a single UITextView ancestor and is iOS-only). onPress works
|
||||
// natively here, so links routed through this span stay tappable on Android.
|
||||
export function MarkdownTextSpan({
|
||||
style,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<Text selectable style={style}>
|
||||
<Text selectable style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import type { StyleProp, TextProps, TextStyle, ViewStyle } from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
// Links route through this span too (see assistant-file-links/link.tsx). A
|
||||
// plain <Text> nested in the paragraph UITextView is dropped, so the link
|
||||
// must be a UITextView span to be visible. onPress is forwarded best-effort:
|
||||
// react-native-uitextview nulls onPress on the root native view, so reliable
|
||||
// tap-to-open is still tracked by #21 — but visible+selectable text beats an
|
||||
// invisible link.
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
// Inline span backed by UITextView so iOS gets native word-selection handles.
|
||||
// Used inside MarkdownParagraphView (which is also a UITextView on iOS); the
|
||||
// library's TextAncestorContext hoists these into UITextViewChild nodes so
|
||||
// selection drags can cross sibling spans (e.g. plain text → **bold** → code).
|
||||
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
|
||||
export function MarkdownTextSpan({
|
||||
style,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<UITextView uiTextView selectable style={style}>
|
||||
<UITextView
|
||||
uiTextView
|
||||
selectable
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
>
|
||||
{children}
|
||||
</UITextView>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
// Web links use the <a>/Pressable path in link.tsx, not this span, so these
|
||||
// are accepted for prop-shape parity with the native variants and forwarded
|
||||
// harmlessly.
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
// react-native-web renders Text as <span>/<div> with `user-select: text`
|
||||
@@ -13,9 +25,20 @@ interface MarkdownTextSpanProps {
|
||||
// react-native-uitextview: its transitive import of codegenNativeComponent
|
||||
// pulls in setUpReactDevTools, which doesn't resolve under Metro's web
|
||||
// target in dev mode.
|
||||
export function MarkdownTextSpan({ style, monoSurface, children }: MarkdownTextSpanProps) {
|
||||
export function MarkdownTextSpan({
|
||||
style,
|
||||
monoSurface,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<Text dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined} style={style}>
|
||||
<Text
|
||||
dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user