fix(app): make markdown links tappable on iOS (#1334)
* fix(app): make markdown links tappable on iOS A markdown link's label renders as nested React elements (link > textgroup > text -> MarkdownInheritedText spans). The link's onPress was placed on the wrapping UITextView span, but react-native-uitextview only attaches onPress to the *string* children it converts into tappable RNUITextViewChild nodes - element children pass through untouched, so the handler never reached a node the native tap recognizer could fire. Link text stayed visible and selectable but dead to taps; web links never opened. Thread the link's press handler down to the leaf text spans via a new AssistantLinkPressProvider so it lands on the real tappable string nodes. MarkdownInheritedText consumes the context and forwards onPress to its MarkdownTextSpan; null outside a link, so ordinary text is unaffected. Gated to iOS - Android forwards onPress through nested <Text> already, and web uses the <a> path. * refactor(app): export useAssistantLinkPress from module index Import it through the assistant-file-links public surface instead of reaching into the internal link-press-context file, matching the rest of message.tsx. Addresses Greptile review feedback on #1334. --------- Co-authored-by: Kevin Aspesi <kevinaspesi@Kevins-Mac-mini.local>
This commit is contained in:
co-authored by
Kevin Aspesi
parent
dfddda7969
commit
350bc08fc4
@@ -3,6 +3,7 @@ export {
|
|||||||
AssistantMarkdownCodeLink,
|
AssistantMarkdownCodeLink,
|
||||||
AssistantMarkdownLink,
|
AssistantMarkdownLink,
|
||||||
} from "./link";
|
} from "./link";
|
||||||
|
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
|
||||||
export {
|
export {
|
||||||
classifyAssistantFileLink,
|
classifyAssistantFileLink,
|
||||||
normalizeInlinePathTarget,
|
normalizeInlinePathTarget,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { TextProps } from "react-native";
|
||||||
|
|
||||||
|
// Carries a link's press handler down to the leaf text spans that render its
|
||||||
|
// label. On iOS an assistant link is a nested UITextView span, and
|
||||||
|
// react-native-uitextview only attaches onPress to the *string* children it
|
||||||
|
// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the
|
||||||
|
// MarkdownInheritedText spans markdown produces for link text) pass through
|
||||||
|
// untouched, so an onPress placed on the wrapping span never reaches a tappable
|
||||||
|
// native node. Threading the handler through context lets each leaf span hand
|
||||||
|
// onPress to its own string children, where the native tap recognizer can find
|
||||||
|
// it. Provided only on iOS (Android/web links tap fine via their own paths).
|
||||||
|
export interface AssistantLinkPress {
|
||||||
|
onPress: () => void;
|
||||||
|
accessibilityRole?: TextProps["accessibilityRole"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssistantLinkPressContext = createContext<AssistantLinkPress | null>(null);
|
||||||
|
|
||||||
|
export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider;
|
||||||
|
|
||||||
|
export function useAssistantLinkPress(): AssistantLinkPress | null {
|
||||||
|
return useContext(AssistantLinkPressContext);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||||
|
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
@@ -56,23 +58,42 @@ export function AssistantMarkdownLink({
|
|||||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||||
[style, hovered],
|
[style, hovered],
|
||||||
);
|
);
|
||||||
|
const linkPress = useMemo<AssistantLinkPress>(
|
||||||
|
() => ({ onPress, accessibilityRole: "link" }),
|
||||||
|
[onPress],
|
||||||
|
);
|
||||||
|
|
||||||
if (isNative) {
|
if (isNative) {
|
||||||
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
|
// 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
|
// inside the paragraph's native UITextView, and a plain <Text> nested there
|
||||||
// is not hoisted into a UITextViewChild, so its text is silently dropped
|
// is not hoisted into a UITextViewChild, so its text is silently dropped
|
||||||
// (the link disappears). The span composes correctly and stays selectable;
|
// (the link disappears). The span composes correctly and stays selectable.
|
||||||
// onPress is forwarded (reliable tap-to-open on iOS is tracked by #21).
|
//
|
||||||
|
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
|
||||||
|
// children it turns into RNUITextViewChild nodes — the element children that
|
||||||
|
// markdown emits for link text pass through untouched, so an onPress placed
|
||||||
|
// here never reaches a tappable native node. We thread it down through
|
||||||
|
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
|
||||||
|
// own string children, where the native tap recognizer can find it. iOS
|
||||||
|
// only: Android forwards onPress through nested <Text> already, and web uses
|
||||||
|
// the <a> path below.
|
||||||
|
const span = (
|
||||||
|
<MarkdownTextSpan
|
||||||
|
accessibilityRole="link"
|
||||||
|
monoSurface={monoSurface}
|
||||||
|
onPress={onPress}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MarkdownTextSpan>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<FileLinkHoverTooltip filePath={tooltipPath}>
|
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||||
<MarkdownTextSpan
|
{Platform.OS === "ios" ? (
|
||||||
accessibilityRole="link"
|
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
|
||||||
monoSurface={monoSurface}
|
) : (
|
||||||
onPress={onPress}
|
span
|
||||||
style={style}
|
)}
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</MarkdownTextSpan>
|
|
||||||
</FileLinkHoverTooltip>
|
</FileLinkHoverTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ interface MarkdownTextSpanProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
// Links route through this span too (see assistant-file-links/link.tsx). A
|
// 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
|
// 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:
|
// must be a UITextView span to be visible. onPress is wired onto the leaf
|
||||||
// react-native-uitextview nulls onPress on the root native view, so reliable
|
// string children here: react-native-uitextview attaches it to the
|
||||||
// tap-to-open is still tracked by #21 — but visible+selectable text beats an
|
// RNUITextViewChild nodes it builds from string content, which the native tap
|
||||||
// invisible link.
|
// recognizer dispatches to. The link's handler reaches these leaf spans via
|
||||||
|
// AssistantLinkPressProvider (see assistant-file-links/link-press-context).
|
||||||
onPress?: TextProps["onPress"];
|
onPress?: TextProps["onPress"];
|
||||||
accessibilityRole?: TextProps["accessibilityRole"];
|
accessibilityRole?: TextProps["accessibilityRole"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import {
|
|||||||
AssistantMarkdownLink,
|
AssistantMarkdownLink,
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
useAssistantFileLinkActions,
|
useAssistantFileLinkActions,
|
||||||
|
useAssistantLinkPress,
|
||||||
} from "@/assistant-file-links";
|
} from "@/assistant-file-links";
|
||||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
@@ -1532,8 +1533,19 @@ function MarkdownInheritedText({
|
|||||||
() => [inheritedStyles, textStyle, overrideStyle],
|
() => [inheritedStyles, textStyle, overrideStyle],
|
||||||
[inheritedStyles, textStyle, overrideStyle],
|
[inheritedStyles, textStyle, overrideStyle],
|
||||||
);
|
);
|
||||||
|
// When this span renders link label text on iOS, pick up the link's press
|
||||||
|
// handler from context and hand it to MarkdownTextSpan, which forwards it to
|
||||||
|
// the leaf string children react-native-uitextview makes tappable. Null
|
||||||
|
// outside a link (and on every other platform, where no provider mounts), so
|
||||||
|
// ordinary text is unaffected. See assistant-file-links/link-press-context.
|
||||||
|
const linkPress = useAssistantLinkPress();
|
||||||
return (
|
return (
|
||||||
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
|
<MarkdownTextSpan
|
||||||
|
monoSurface={monoSurface}
|
||||||
|
style={style}
|
||||||
|
onPress={linkPress?.onPress}
|
||||||
|
accessibilityRole={linkPress?.accessibilityRole}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</MarkdownTextSpan>
|
</MarkdownTextSpan>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user