authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 13:03:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 13:03:27-07:00
log9159b808d35a88e74fc91e11070f3270cdb321f9
treee18eed34f65a9da49a02861e81e563164193f5b6
parent2a7a449cca3c7760b689a54b3aa02476a206dbc9
signature Signed by PGP key B5690EEEBB952194

feat(mobile): long-press file references for path and open actions (#9258)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

17 files changed, 411 insertions(+), 54 deletions(-)

apps/mobile/modules/t3-markdown-text/index.ts+2
......@@ -21,6 +21,8 @@ export {
2121 type MarkdownHighlightedToken,
2222} from "./src/SelectableMarkdownText";
2323export type {
24 MarkdownFileContextMenu,
25 MarkdownFileContextMenuAction,
2426 NativeMarkdownTextStyle,
2527 SelectableMarkdownSkill,
2628 SelectableMarkdownTextProps,
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+92-9
......@@ -198,6 +198,8 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
198198 BOOL _suppressSelectionChange;
199199 NSMutableDictionary<NSString *, UIImage *> * _attachmentImages;
200200 NSMutableSet<NSString *> * _pendingAttachmentUris;
201 UILongPressGestureRecognizer *_longPressGestureRecognizer;
202 UITapGestureRecognizer *_pressGestureRecognizer;
201203}
202204
203205+ (ComponentDescriptorProvider)componentDescriptorProvider
......@@ -223,21 +225,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
223225 _textView.textContainerInset = UIEdgeInsetsZero;
224226 _textView.textContainer.lineFragmentPadding = 0;
225227 _textView.delegate = self;
228 // Chat text supports selection and contextual actions, but not drag-and-drop.
229 _textView.textDragInteraction.enabled = NO;
230 _textView.linkTextAttributes = @{};
226231 // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO.
227232 _textView.layoutManager.usesFontLeading = NO;
228233 [self addSubview:_textView];
229234
230 const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
231 action:@selector(handleLongPressIfNecessary:)];
232 longPressGestureRecognizer.delegate = self;
235 _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
236 action:@selector(handleLongPressIfNecessary:)];
237 _longPressGestureRecognizer.delegate = self;
233238
234 const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
235 action:@selector(handlePressIfNecessary:)];
236 pressGestureRecognizer.delegate = self;
237 [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer];
239 _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
240 action:@selector(handlePressIfNecessary:)];
241 _pressGestureRecognizer.delegate = self;
242 [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer];
238243
239 [_textView addGestureRecognizer:pressGestureRecognizer];
240 [_textView addGestureRecognizer:longPressGestureRecognizer];
244 [_textView addGestureRecognizer:_pressGestureRecognizer];
245 [_textView addGestureRecognizer:_longPressGestureRecognizer];
241246 }
242247
243248 return self;
......@@ -312,6 +317,26 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
312317 convertedAttrString,
313318 _state->getData().attachmentRanges,
314319 _attachmentImages);
320 NSUInteger runLocation = 0;
321 for (UIView *child in self.subviews) {
322 if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
323 continue;
324 }
325
326 T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child;
327 const NSRange runRange = NSMakeRange(runLocation, textChild.text.length);
328 runLocation = NSMaxRange(runRange);
329 if (![textChild hasContextMenu] || runRange.length == 0 ||
330 NSMaxRange(runRange) > convertedAttrString.length) {
331 continue;
332 }
333
334 NSURL *link = [NSURL URLWithString:
335 [NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]];
336 if (link != nil) {
337 [convertedAttrString addAttribute:NSLinkAttributeName value:link range:runRange];
338 }
339 }
315340 [self loadAttachmentImages:_state->getData().attachmentRanges];
316341
317342 // Setting attributedText clears any active text selection, and re-assigning
......@@ -484,6 +509,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
484509 return YES;
485510}
486511
512- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
513{
514 if (gestureRecognizer != _longPressGestureRecognizer &&
515 gestureRecognizer != _pressGestureRecognizer) {
516 return YES;
517 }
518
519 const auto location = [self getLocationOfPress:gestureRecognizer];
520 const auto child = [self getTouchChild:location];
521 return ![child hasContextMenu];
522}
523
487524- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
488525{
489526 return YES;
......@@ -507,6 +544,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
507544
508545// MARK: - Touch handling
509546
547- (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange
548{
549 NSUInteger location = 0;
550 for (UIView *child in self.subviews) {
551 if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
552 continue;
553 }
554
555 T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child;
556 const NSRange range = NSMakeRange(location, textChild.text.length);
557 if (NSIntersectionRange(range, characterRange).length > 0) {
558 return textChild;
559 }
560 location = NSMaxRange(range);
561 }
562 return nil;
563}
564
510565- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender
511566{
512567 return [sender locationInView:_textView];
......@@ -550,6 +605,10 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
550605
551606- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender
552607{
608 if (sender.state != UIGestureRecognizerStateBegan) {
609 return;
610 }
611
553612 const auto location = [self getLocationOfPress:sender];
554613 const auto child = [self getTouchChild:location];
555614
......@@ -560,6 +619,30 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
560619
561620// MARK: - UITextViewDelegate
562621
622- (nullable UIAction *)textView:(UITextView *)textView
623 primaryActionForTextItem:(UITextItem *)textItem
624 defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0))
625{
626 T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range];
627 if (![child hasContextMenu]) {
628 return defaultAction;
629 }
630
631 __weak T3MarkdownTextRun *weakChild = child;
632 return [UIAction actionWithHandler:^(__kindof UIAction *action) {
633 [weakChild onPress];
634 }];
635}
636
637- (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView
638 menuConfigurationForTextItem:(UITextItem *)textItem
639 defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0))
640{
641 T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range];
642 UIMenu *menu = [child contextMenu];
643 return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu];
644}
645
563646- (void)textViewDidChangeSelection:(UITextView *)textView
564647{
565648 if (_suppressSelectionChange) {
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h+3
......@@ -13,6 +13,9 @@ NS_ASSUME_NONNULL_BEGIN
1313
1414@property (nonatomic, copy, nullable) NSString *text;
1515
16- (nullable UIMenu *)contextMenu;
17- (BOOL)hasContextMenu;
18- (void)onContextMenuAction:(NSString *)actionIdentifier;
1619- (void)onPress;
1720- (void)onLongPress;
1821
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm+70-2
......@@ -15,8 +15,7 @@ using namespace facebook::react;
1515
1616@implementation T3MarkdownTextRun {
1717 NSString * _text;
18 RCTBubblingEventBlock _onPress;
19 RCTBubblingEventBlock _onLongPress;
18 NSString * _contextMenuConfig;
2019}
2120
2221+ (ComponentDescriptorProvider)componentDescriptorProvider
......@@ -43,9 +42,78 @@ using namespace facebook::react;
4342 _text = text;
4443 }
4544
45 if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) {
46 _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()];
47 }
48
4649 [super updateProps:props oldProps:oldProps];
4750}
4851
52- (BOOL)hasContextMenu
53{
54 return _contextMenuConfig.length > 0;
55}
56
57- (nullable UIMenu *)contextMenu
58{
59 if (_contextMenuConfig.length == 0) {
60 return nil;
61 }
62
63 NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding];
64 NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
65 if (![config isKindOfClass:[NSDictionary class]]) {
66 return nil;
67 }
68
69 NSArray *actionConfigs = config[@"actions"];
70 if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) {
71 return nil;
72 }
73
74 NSMutableArray<UIMenuElement *> *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count];
75 __weak T3MarkdownTextRun *weakSelf = self;
76 for (NSDictionary *actionConfig in actionConfigs) {
77 if (![actionConfig isKindOfClass:[NSDictionary class]]) {
78 continue;
79 }
80 NSString *actionIdentifier = actionConfig[@"id"];
81 NSString *title = actionConfig[@"title"];
82 if (![actionIdentifier isKindOfClass:[NSString class]] ||
83 ![title isKindOfClass:[NSString class]]) {
84 continue;
85 }
86
87 UIAction *action = [UIAction actionWithTitle:title
88 image:nil
89 identifier:actionIdentifier
90 handler:^(__kindof UIAction *selectedAction) {
91 [weakSelf onContextMenuAction:selectedAction.identifier];
92 }];
93 if ([actionConfig[@"disabled"] boolValue]) {
94 action.attributes = UIMenuElementAttributesDisabled;
95 }
96 [actions addObject:action];
97 }
98
99 if (actions.count == 0) {
100 return nil;
101 }
102 NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @"";
103 return [UIMenu menuWithTitle:title children:actions];
104}
105
106- (void)onContextMenuAction:(NSString *)actionIdentifier
107{
108 if (_eventEmitter != nullptr) {
109 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)
110 ->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{
111 static_cast<int>(self.tag),
112 actionIdentifier.UTF8String,
113 });
114 }
115}
116
49117- (void)onPress {
50118 if (_eventEmitter != nullptr) {
51119 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)
apps/mobile/modules/t3-markdown-text/src/MarkdownTextPrimitive.tsx+6
......@@ -24,8 +24,14 @@ export type SelectionChangeEvent = {
2424 nativeEvent: { target: number; start: number; end: number };
2525};
2626
27export type ContextMenuActionEvent = {
28 nativeEvent: { target: number; actionIdentifier: string };
29};
30
2731export type MarkdownTextPrimitiveProps = TextProps & {
2832 uiTextView?: boolean;
33 contextMenuConfig?: string;
34 onContextMenuAction?: (event: ContextMenuActionEvent) => void;
2935 /**
3036 * Fired when the native text selection changes. Only fires on iOS when
3137 * `uiTextView` is true. Note: fires on every selection-edge adjustment
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx+23-1
......@@ -1,9 +1,23 @@
1import { createContext, useContext } from "react";
12import { Image, Linking, type TextStyle, useColorScheme } from "react-native";
23
34import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
45import { markdownFileIconSource } from "./markdownFileIcons";
56import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
6import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types";
7import type {
8 MarkdownFileContextMenu,
9 NativeMarkdownTextStyle,
10} from "./SelectableMarkdownText.types";
11
12export interface MarkdownFileContextMenuHandlers {
13 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
14 readonly onFileContextMenuAction: (href: string, actionId: string) => void;
15}
16
17/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */
18export const MarkdownFileContextMenuContext = createContext<MarkdownFileContextMenuHandlers | null>(
19 null,
20);
721
822const EXTERNAL_LINK_PREFIX = "◉ ";
923const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
......@@ -139,6 +153,7 @@ export function NativeMarkdownSelectableText(props: {
139153 readonly onLinkPress?: (href: string) => void;
140154}) {
141155 const colorScheme = useColorScheme();
156 const menu = useContext(MarkdownFileContextMenuContext);
142157 const occurrences = new Map<string, number>();
143158 const prefixedExternalLinks = new Set<string>();
144159 const keyedRuns = props.runs.map((run) => {
......@@ -195,6 +210,7 @@ export function NativeMarkdownSelectableText(props: {
195210 >
196211 {keyedRuns.map(({ key, run, text }) => {
197212 const href = run.href;
213 const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;
198214 return (
199215 <MarkdownTextPrimitive
200216 key={key}
......@@ -205,6 +221,7 @@ export function NativeMarkdownSelectableText(props: {
205221 ? "t3-skill:sf:cube"
206222 : undefined
207223 }
224 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
208225 style={runStyle(run, props.textStyle)}
209226 onPress={
210227 href
......@@ -217,6 +234,11 @@ export function NativeMarkdownSelectableText(props: {
217234 }
218235 : undefined
219236 }
237 onContextMenuAction={
238 contextMenu && href && menu
239 ? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier)
240 : undefined
241 }
220242 >
221243 {text}
222244 </MarkdownTextPrimitive>
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+46-30
......@@ -9,7 +9,11 @@ import {
99 nativeMarkdownWithPreservedSoftBreaks,
1010} from "./nativeMarkdownText";
1111import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
12import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
12import {
13 MarkdownFileContextMenuContext,
14 NativeMarkdownSelectableText,
15 type MarkdownFileContextMenuHandlers,
16} from "./NativeMarkdownSelectableText.ios";
1317import type {
1418 SelectableMarkdownSkill,
1519 SelectableMarkdownTextProps,
......@@ -38,6 +42,8 @@ export function SelectableMarkdownText({
3842 highlightCode,
3943 preserveSoftBreaks = false,
4044 onLinkPress,
45 fileContextMenu,
46 onFileContextMenuAction,
4147 renderImage,
4248 marginTop = 0,
4349 marginBottom = 0,
......@@ -61,41 +67,51 @@ export function SelectableMarkdownText({
6167 );
6268 }, [markdown, preserveSoftBreaks, skills]);
6369
70 const fileContextMenuHandlers = useMemo<MarkdownFileContextMenuHandlers | null>(
71 () =>
72 fileContextMenu && onFileContextMenuAction
73 ? { fileContextMenu, onFileContextMenuAction }
74 : null,
75 [fileContextMenu, onFileContextMenuAction],
76 );
77
6478 return (
6579 <MarkdownImageRendererContext.Provider value={renderImage ?? null}>
66 {/* A percentage width here creates a cyclic intrinsic measurement inside
80 <MarkdownFileContextMenuContext.Provider value={fileContextMenuHandlers}>
81 {/* A percentage width here creates a cyclic intrinsic measurement inside
6782 shrink-to-fit containers such as user-message bubbles. Yoga then gives
6883 the native text node an unbounded second pass and the parent only clips
6984 the resulting single-line width instead of reflowing it. */}
70 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
71 {chunks.map((chunk, index) => {
72 const content =
73 chunk.kind === "rich" ? (
74 <NativeMarkdownBlock
75 node={chunk.node}
76 skills={skills}
77 textStyle={textStyle}
78 highlightCode={highlightCode}
79 onLinkPress={onLinkPress}
80 />
81 ) : (
82 <NativeMarkdownSelectableText
83 runs={chunk.runs}
84 textStyle={textStyle}
85 onLinkPress={onLinkPress}
86 />
87 );
85 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
86 {chunks.map((chunk, index) => {
87 const content =
88 chunk.kind === "rich" ? (
89 <NativeMarkdownBlock
90 node={chunk.node}
91 skills={skills}
92 textStyle={textStyle}
93 highlightCode={highlightCode}
94 onLinkPress={onLinkPress}
95 />
96 ) : (
97 <NativeMarkdownSelectableText
98 runs={chunk.runs}
99 textStyle={textStyle}
100 onLinkPress={onLinkPress}
101 />
102 );
88103
89 return (
90 <View
91 key={chunk.key}
92 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
93 >
94 {content}
95 </View>
96 );
97 })}
98 </View>
104 return (
105 <View
106 key={chunk.key}
107 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
108 >
109 {content}
110 </View>
111 );
112 })}
113 </View>
114 </MarkdownFileContextMenuContext.Provider>
99115 </MarkdownImageRendererContext.Provider>
100116 );
101117}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+13
......@@ -50,6 +50,17 @@ export interface MarkdownImageRequest {
5050 */
5151export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode;
5252
53export interface MarkdownFileContextMenuAction {
54 readonly id: string;
55 readonly title: string;
56 readonly disabled?: boolean;
57}
58
59export interface MarkdownFileContextMenu {
60 readonly title?: string;
61 readonly actions: ReadonlyArray<MarkdownFileContextMenuAction>;
62}
63
5364export interface SelectableMarkdownTextProps {
5465 readonly markdown: string;
5566 readonly textStyle: NativeMarkdownTextStyle;
......@@ -57,6 +68,8 @@ export interface SelectableMarkdownTextProps {
5768 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
5869 readonly preserveSoftBreaks?: boolean;
5970 readonly onLinkPress?: (href: string) => void;
71 readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined;
72 readonly onFileContextMenuAction?: (href: string, actionId: string) => void;
6073 readonly renderImage?: MarkdownImageRenderer;
6174 readonly marginTop?: number;
6275 readonly marginBottom?: number;
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts+6
......@@ -11,6 +11,10 @@ interface TargetedEvent {
1111 target: Int32;
1212}
1313
14interface ContextMenuActionEvent extends TargetedEvent {
15 actionIdentifier: string;
16}
17
1418type TextDecorationLine = "none" | "underline" | "line-through";
1519
1620type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed";
......@@ -42,8 +46,10 @@ interface NativeProps extends ViewProps {
4246 textDecorationColor?: ColorValue;
4347 textAlign?: WithDefault<TextAlign, "auto">;
4448 shadowRadius?: WithDefault<Float, 0>;
49 contextMenuConfig?: string;
4550 onPress?: BubblingEventHandler<TargetedEvent>;
4651 onLongPress?: BubblingEventHandler<TargetedEvent>;
52 onContextMenuAction?: BubblingEventHandler<ContextMenuActionEvent>;
4753}
4854
4955export default codegenNativeComponent<NativeProps>("T3MarkdownTextRun", {
apps/mobile/src/features/files/filePath.test.ts+1
......@@ -32,6 +32,7 @@ describe("resolveWorkspaceRelativeFilePath", () => {
3232 it("rejects paths outside the workspace", () => {
3333 expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull();
3434 expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull();
35 expect(resolveWorkspaceRelativeFilePath("/repo", "/repo/../outside.txt")).toBeNull();
3536 expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull();
3637 });
3738});
apps/mobile/src/features/files/filePath.ts+6-1
......@@ -88,7 +88,12 @@ export function resolveWorkspaceRelativeFilePath(
8888 return null;
8989 }
9090
91 return normalizeRelativePath(normalizedTarget.slice(normalizedRoot.length + 1));
91 const relativePath = normalizedTarget.slice(normalizedRoot.length + 1);
92 // `/repo/../x` starts with the root but escapes it.
93 if (relativePath.split("/").includes("..")) {
94 return null;
95 }
96 return normalizeRelativePath(relativePath);
9297}
9398
9499export function isVideoPreviewFile(path: string): boolean {
apps/mobile/src/features/threads/ThreadFeed.tsx+44-10
......@@ -91,6 +91,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
9191import {
9292 hasNativeSelectableMarkdownText,
9393 SelectableMarkdownText,
94 type MarkdownFileContextMenu,
9495 type MarkdownImageRenderer,
9596 type NativeMarkdownTextStyle,
9697 type SelectableMarkdownSkill,
......@@ -176,6 +177,7 @@ import {
176177 resolveWorkspaceRelativeFilePath,
177178} from "../files/filePath";
178179import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
180import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
179181
180182const WIDE_MARKDOWN_BLOCK_OPTIONS = {
181183 // Native iOS blockquotes and adjacent selectable text are separate layout
......@@ -872,10 +874,17 @@ function ArtifactTemplateCard(props: {
872874 );
873875}
874876
877/** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */
878interface MarkdownLinkHandlers {
879 readonly onLinkPress: (href: string) => void;
880 readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
881 readonly onFileContextMenuAction: (href: string, actionId: string) => void;
882}
883
875884const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
876885 readonly markdown: string;
877886 readonly markdownStyles: MarkdownStyleSet;
878 readonly onLinkPress: (href: string) => void;
887 readonly linkHandlers: MarkdownLinkHandlers;
879888 readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
880889 readonly renderImage: MarkdownImageRenderer;
881890 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
......@@ -904,7 +913,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
904913 markdown={markdown}
905914 skills={props.skills}
906915 textStyle={props.markdownStyles.nativeTextStyle}
907 onLinkPress={props.onLinkPress}
916 {...props.linkHandlers}
908917 renderImage={props.renderImage}
909918 />
910919 ) : (
......@@ -1465,7 +1474,7 @@ function renderFeedEntry(
14651474 readonly onToggleTurnFold: (turnId: TurnId) => void;
14661475 readonly onPressPreview: (source: FilePreviewSource) => void;
14671476 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
1468 readonly onMarkdownLinkPress: (href: string) => void;
1477 readonly markdownLinkHandlers: MarkdownLinkHandlers;
14691478 readonly renderMarkdownImage: MarkdownImageRenderer;
14701479 readonly renderViewedImage: MarkdownImageRenderer;
14711480 readonly iconSubtleColor: string | import("react-native").ColorValue;
......@@ -1573,7 +1582,7 @@ function renderFeedEntry(
15731582 markdownStyles={styles}
15741583 reviewCommentColors={props.reviewCommentColors}
15751584 skills={props.skills}
1576 onLinkPress={props.onMarkdownLinkPress}
1585 linkHandlers={props.markdownLinkHandlers}
15771586 renderImage={props.renderMarkdownImage}
15781587 />
15791588 ) : null}
......@@ -1634,7 +1643,7 @@ function renderFeedEntry(
16341643 <AssistantMarkdownContent
16351644 markdown={renderedText}
16361645 markdownStyles={styles}
1637 onLinkPress={props.onMarkdownLinkPress}
1646 linkHandlers={props.markdownLinkHandlers}
16381647 onUseArtifactTemplate={props.onUseArtifactTemplate}
16391648 renderImage={props.renderMarkdownImage}
16401649 skills={props.skills}
......@@ -1704,7 +1713,7 @@ function UserMessageContent(props: {
17041713 readonly markdownStyles: MarkdownStyleSet;
17051714 readonly reviewCommentColors: ReviewCommentColors;
17061715 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
1707 readonly onLinkPress: (href: string) => void;
1716 readonly linkHandlers: MarkdownLinkHandlers;
17081717 readonly renderImage: MarkdownImageRenderer;
17091718}) {
17101719 const segments = parseReviewCommentMessageSegments(props.text);
......@@ -1717,7 +1726,7 @@ function UserMessageContent(props: {
17171726 skills={props.skills}
17181727 textStyle={props.markdownStyles.nativeTextStyle}
17191728 preserveSoftBreaks
1720 onLinkPress={props.onLinkPress}
1729 {...props.linkHandlers}
17211730 renderImage={props.renderImage}
17221731 />
17231732 );
......@@ -1759,7 +1768,7 @@ function UserMessageContent(props: {
17591768 skills={props.skills}
17601769 textStyle={props.markdownStyles.nativeTextStyle}
17611770 preserveSoftBreaks
1762 onLinkPress={props.onLinkPress}
1771 {...props.linkHandlers}
17631772 renderImage={props.renderImage}
17641773 />
17651774 ) : (
......@@ -2171,6 +2180,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
21712180 },
21722181 [props.environmentId, props.threadId, props.workspaceRoot, navigation],
21732182 );
2183 const markdownLinkHandlers = useMemo<MarkdownLinkHandlers>(
2184 () => ({
2185 onLinkPress: onMarkdownLinkPress,
2186 fileContextMenu: (href) => {
2187 const target = resolveFileChipTarget(href, props.workspaceRoot);
2188 return target ? fileChipMenu(target) : undefined;
2189 },
2190 onFileContextMenuAction: (href, actionId) => {
2191 const target = resolveFileChipTarget(href, props.workspaceRoot);
2192 if (!target) return;
2193 switch (actionId as FileChipAction) {
2194 case "copy-full-path":
2195 if (target.fullPath) copyTextWithHaptic(target.fullPath);
2196 return;
2197 case "copy-relative-path":
2198 if (target.relativePath) copyTextWithHaptic(target.relativePath);
2199 return;
2200 case "open-file":
2201 onMarkdownLinkPress(href);
2202 return;
2203 }
2204 },
2205 }),
2206 [onMarkdownLinkPress, props.workspaceRoot],
2207 );
21742208 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
21752209 (image) => {
21762210 const media = resolveMarkdownMediaPreview(image.href, {
......@@ -2696,7 +2730,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
26962730 onToggleTurnFold,
26972731 onPressPreview,
26982732 onPressVideo,
2699 onMarkdownLinkPress,
2733 markdownLinkHandlers,
27002734 renderMarkdownImage,
27012735 renderViewedImage,
27022736 iconSubtleColor,
......@@ -2726,7 +2760,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
27262760 reviewCommentBubbleWidth,
27272761 userBubbleMaxWidth,
27282762 onCopyWorkRow,
2729 onMarkdownLinkPress,
2763 markdownLinkHandlers,
27302764 onPressPreview,
27312765 onPressVideo,
27322766 onToggleTurnFold,
apps/mobile/src/features/threads/fileChipMenu.test.ts created+44
......@@ -0,0 +1,44 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { fileChipMenu, resolveFileChipTarget } from "./fileChipMenu";
4
5describe("resolveFileChipTarget", () => {
6 it("resolves a workspace-relative link to both paths", () => {
7 expect(resolveFileChipTarget("src/app.ts:12", "/repo")).toEqual({
8 fullPath: "/repo/src/app.ts",
9 relativePath: "src/app.ts",
10 });
11 });
12
13 it("keeps only the full path for a host file outside the workspace", () => {
14 expect(resolveFileChipTarget("/tmp/report.md", "/repo")).toEqual({
15 fullPath: "/tmp/report.md",
16 });
17 });
18
19 it("keeps only the relative path when the workspace root is unknown", () => {
20 expect(resolveFileChipTarget("src/app.ts", null)).toEqual({ relativePath: "src/app.ts" });
21 });
22
23 it("ignores links that are not files or cannot be opened", () => {
24 expect(resolveFileChipTarget("https://example.com/app.ts", "/repo")).toBeNull();
25 expect(resolveFileChipTarget("~/report.md", "/repo")).toBeNull();
26 expect(resolveFileChipTarget("../other/file.ts", "/repo")).toBeNull();
27 });
28});
29
30describe("fileChipMenu", () => {
31 it("offers only the copies the target can satisfy", () => {
32 expect(fileChipMenu({ fullPath: "/tmp/report.md" })).toEqual({
33 title: "/tmp/report.md",
34 actions: [
35 { id: "copy-full-path", title: "Copy full path" },
36 { id: "open-file", title: "Open in file viewer" },
37 ],
38 });
39 expect(fileChipMenu({ relativePath: "src/app.ts" }).actions.map(({ id }) => id)).toEqual([
40 "copy-relative-path",
41 "open-file",
42 ]);
43 });
44});
apps/mobile/src/features/threads/fileChipMenu.ts created+49
......@@ -0,0 +1,49 @@
1import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links";
2import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types";
3
4import {
5 isAbsolutePath,
6 resolveWorkspaceFilePath,
7 resolveWorkspaceRelativeFilePath,
8} from "../files/filePath";
9
10export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file";
11
12export interface FileChipTarget {
13 /** The host path, when the link is absolute or the workspace root is known. */
14 readonly fullPath?: string;
15 /** The path inside the workspace, when the link resolves there. */
16 readonly relativePath?: string;
17}
18
19/** Null when the link is not a file or resolves nowhere the feed can open, such as `~/x` or `../x`. */
20export function resolveFileChipTarget(
21 href: string,
22 workspaceRoot: string | null | undefined,
23): FileChipTarget | null {
24 const presentation = resolveMarkdownLinkPresentation(href);
25 if (presentation.kind !== "file") return null;
26 const relativePath = resolveWorkspaceRelativeFilePath(workspaceRoot, presentation.path);
27 const fullPath = isAbsolutePath(presentation.path)
28 ? presentation.path
29 : workspaceRoot && relativePath
30 ? resolveWorkspaceFilePath(workspaceRoot, relativePath)
31 : undefined;
32 if (!fullPath && !relativePath) return null;
33 return {
34 ...(fullPath ? { fullPath } : {}),
35 ...(relativePath ? { relativePath } : {}),
36 };
37}
38
39/** The same actions the web file chip offers on right-click. Opening is what a tap does. */
40export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu {
41 return {
42 title: target.fullPath ?? target.relativePath ?? "",
43 actions: [
44 ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []),
45 ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []),
46 { id: "open-file", title: "Open in file viewer" },
47 ],
48 };
49}
apps/mobile/src/native/SelectableMarkdownText.ios.tsx+2
......@@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"
88type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
99
1010export type {
11 MarkdownFileContextMenu,
12 MarkdownFileContextMenuAction,
1113 MarkdownImageRenderer,
1214 MarkdownImageRequest,
1315 NativeMarkdownTextStyle,
apps/mobile/src/native/SelectableMarkdownText.tsx+2
......@@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/
33type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
44
55export type {
6 MarkdownFileContextMenu,
7 MarkdownFileContextMenuAction,
68 MarkdownImageRenderer,
79 MarkdownImageRequest,
810 NativeMarkdownTextStyle,
docs/user/composer.md+2-1
......@@ -107,7 +107,8 @@ path** and **Open in file viewer**. These actions are available in expanded prev
107107On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its
108108source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file
109109viewer from the same menu. Saving downloads a copy only when you request it; it does not change
110how the video buffers during playback.
110how the video buffers during playback. On iOS, touch and hold a file reference in a message to
111copy its full or relative path or open it in the file viewer.
111112
112113Use Markdown image syntax to embed either kind of media:
113114