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 {...@@ -21,6 +21,8 @@ export {
21 type MarkdownHighlightedToken,21 type MarkdownHighlightedToken,
22} from "./src/SelectableMarkdownText";22} from "./src/SelectableMarkdownText";
23export type {23export type {
24 MarkdownFileContextMenu,
25 MarkdownFileContextMenuAction,
24 NativeMarkdownTextStyle,26 NativeMarkdownTextStyle,
25 SelectableMarkdownSkill,27 SelectableMarkdownSkill,
26 SelectableMarkdownTextProps,28 SelectableMarkdownTextProps,
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm+92-9
...@@ -198,6 +198,8 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -198,6 +198,8 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
198 BOOL _suppressSelectionChange;198 BOOL _suppressSelectionChange;
199 NSMutableDictionary<NSString *, UIImage *> * _attachmentImages;199 NSMutableDictionary<NSString *, UIImage *> * _attachmentImages;
200 NSMutableSet<NSString *> * _pendingAttachmentUris;200 NSMutableSet<NSString *> * _pendingAttachmentUris;
201 UILongPressGestureRecognizer *_longPressGestureRecognizer;
202 UITapGestureRecognizer *_pressGestureRecognizer;
201}203}
202204
203+ (ComponentDescriptorProvider)componentDescriptorProvider205+ (ComponentDescriptorProvider)componentDescriptorProvider
...@@ -223,21 +225,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -223,21 +225,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
223 _textView.textContainerInset = UIEdgeInsetsZero;225 _textView.textContainerInset = UIEdgeInsetsZero;
224 _textView.textContainer.lineFragmentPadding = 0;226 _textView.textContainer.lineFragmentPadding = 0;
225 _textView.delegate = self;227 _textView.delegate = self;
228 // Chat text supports selection and contextual actions, but not drag-and-drop.
229 _textView.textDragInteraction.enabled = NO;
230 _textView.linkTextAttributes = @{};
226 // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO.231 // Must match RCTTextLayoutManager, which measures with usesFontLeading = NO.
227 _textView.layoutManager.usesFontLeading = NO;232 _textView.layoutManager.usesFontLeading = NO;
228 [self addSubview:_textView];233 [self addSubview:_textView];
229234
230 const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self235 _longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
231 action:@selector(handleLongPressIfNecessary:)];236 action:@selector(handleLongPressIfNecessary:)];
232 longPressGestureRecognizer.delegate = self;237 _longPressGestureRecognizer.delegate = self;
233238
234 const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self239 _pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
235 action:@selector(handlePressIfNecessary:)];240 action:@selector(handlePressIfNecessary:)];
236 pressGestureRecognizer.delegate = self;241 _pressGestureRecognizer.delegate = self;
237 [pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer];242 [_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer];
238243
239 [_textView addGestureRecognizer:pressGestureRecognizer];244 [_textView addGestureRecognizer:_pressGestureRecognizer];
240 [_textView addGestureRecognizer:longPressGestureRecognizer];245 [_textView addGestureRecognizer:_longPressGestureRecognizer];
241 }246 }
242247
243 return self;248 return self;
...@@ -312,6 +317,26 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -312,6 +317,26 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
312 convertedAttrString,317 convertedAttrString,
313 _state->getData().attachmentRanges,318 _state->getData().attachmentRanges,
314 _attachmentImages);319 _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 }
315 [self loadAttachmentImages:_state->getData().attachmentRanges];340 [self loadAttachmentImages:_state->getData().attachmentRanges];
316341
317 // Setting attributedText clears any active text selection, and re-assigning342 // Setting attributedText clears any active text selection, and re-assigning
...@@ -484,6 +509,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -484,6 +509,18 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
484 return YES;509 return YES;
485}510}
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
487- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch524- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
488{525{
489 return YES;526 return YES;
...@@ -507,6 +544,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -507,6 +544,24 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
507544
508// MARK: - Touch handling545// 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
510- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender565- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender
511{566{
512 return [sender locationInView:_textView];567 return [sender locationInView:_textView];
...@@ -550,6 +605,10 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -550,6 +605,10 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
550605
551- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender606- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender
552{607{
608 if (sender.state != UIGestureRecognizerStateBegan) {
609 return;
610 }
611
553 const auto location = [self getLocationOfPress:sender];612 const auto location = [self getLocationOfPress:sender];
554 const auto child = [self getTouchChild:location];613 const auto child = [self getTouchChild:location];
555614
...@@ -560,6 +619,30 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)...@@ -560,6 +619,30 @@ T3MarkdownOutsideTapCoordinatorForWindow(UIWindow *window)
560619
561// MARK: - UITextViewDelegate620// 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
563- (void)textViewDidChangeSelection:(UITextView *)textView646- (void)textViewDidChangeSelection:(UITextView *)textView
564{647{
565 if (_suppressSelectionChange) {648 if (_suppressSelectionChange) {
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h+3
...@@ -13,6 +13,9 @@ NS_ASSUME_NONNULL_BEGIN...@@ -13,6 +13,9 @@ NS_ASSUME_NONNULL_BEGIN
1313
14@property (nonatomic, copy, nullable) NSString *text;14@property (nonatomic, copy, nullable) NSString *text;
1515
16- (nullable UIMenu *)contextMenu;
17- (BOOL)hasContextMenu;
18- (void)onContextMenuAction:(NSString *)actionIdentifier;
16- (void)onPress;19- (void)onPress;
17- (void)onLongPress;20- (void)onLongPress;
1821
apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm+70-2
...@@ -15,8 +15,7 @@ using namespace facebook::react;...@@ -15,8 +15,7 @@ using namespace facebook::react;
1515
16@implementation T3MarkdownTextRun {16@implementation T3MarkdownTextRun {
17 NSString * _text;17 NSString * _text;
18 RCTBubblingEventBlock _onPress;18 NSString * _contextMenuConfig;
19 RCTBubblingEventBlock _onLongPress;
20}19}
2120
22+ (ComponentDescriptorProvider)componentDescriptorProvider21+ (ComponentDescriptorProvider)componentDescriptorProvider
...@@ -43,9 +42,78 @@ using namespace facebook::react;...@@ -43,9 +42,78 @@ using namespace facebook::react;
43 _text = text;42 _text = text;
44 }43 }
4544
45 if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) {
46 _contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()];
47 }
48
46 [super updateProps:props oldProps:oldProps];49 [super updateProps:props oldProps:oldProps];
47}50}
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
49- (void)onPress {117- (void)onPress {
50 if (_eventEmitter != nullptr) {118 if (_eventEmitter != nullptr) {
51 std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)119 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 = {...@@ -24,8 +24,14 @@ export type SelectionChangeEvent = {
24 nativeEvent: { target: number; start: number; end: number };24 nativeEvent: { target: number; start: number; end: number };
25};25};
2626
27export type ContextMenuActionEvent = {
28 nativeEvent: { target: number; actionIdentifier: string };
29};
30
27export type MarkdownTextPrimitiveProps = TextProps & {31export type MarkdownTextPrimitiveProps = TextProps & {
28 uiTextView?: boolean;32 uiTextView?: boolean;
33 contextMenuConfig?: string;
34 onContextMenuAction?: (event: ContextMenuActionEvent) => void;
29 /**35 /**
30 * Fired when the native text selection changes. Only fires on iOS when36 * Fired when the native text selection changes. Only fires on iOS when
31 * `uiTextView` is true. Note: fires on every selection-edge adjustment37 * `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 @@...@@ -1,9 +1,23 @@
1import { createContext, useContext } from "react";
1import { Image, Linking, type TextStyle, useColorScheme } from "react-native";2import { Image, Linking, type TextStyle, useColorScheme } from "react-native";
23
3import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";4import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
4import { markdownFileIconSource } from "./markdownFileIcons";5import { markdownFileIconSource } from "./markdownFileIcons";
5import type { NativeMarkdownTextRun } from "./nativeMarkdownText";6import 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
8const EXTERNAL_LINK_PREFIX = "◉ ";22const EXTERNAL_LINK_PREFIX = "◉ ";
9const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";23const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
...@@ -139,6 +153,7 @@ export function NativeMarkdownSelectableText(props: {...@@ -139,6 +153,7 @@ export function NativeMarkdownSelectableText(props: {
139 readonly onLinkPress?: (href: string) => void;153 readonly onLinkPress?: (href: string) => void;
140}) {154}) {
141 const colorScheme = useColorScheme();155 const colorScheme = useColorScheme();
156 const menu = useContext(MarkdownFileContextMenuContext);
142 const occurrences = new Map<string, number>();157 const occurrences = new Map<string, number>();
143 const prefixedExternalLinks = new Set<string>();158 const prefixedExternalLinks = new Set<string>();
144 const keyedRuns = props.runs.map((run) => {159 const keyedRuns = props.runs.map((run) => {
...@@ -195,6 +210,7 @@ export function NativeMarkdownSelectableText(props: {...@@ -195,6 +210,7 @@ export function NativeMarkdownSelectableText(props: {
195 >210 >
196 {keyedRuns.map(({ key, run, text }) => {211 {keyedRuns.map(({ key, run, text }) => {
197 const href = run.href;212 const href = run.href;
213 const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;
198 return (214 return (
199 <MarkdownTextPrimitive215 <MarkdownTextPrimitive
200 key={key}216 key={key}
...@@ -205,6 +221,7 @@ export function NativeMarkdownSelectableText(props: {...@@ -205,6 +221,7 @@ export function NativeMarkdownSelectableText(props: {
205 ? "t3-skill:sf:cube"221 ? "t3-skill:sf:cube"
206 : undefined222 : undefined
207 }223 }
224 contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
208 style={runStyle(run, props.textStyle)}225 style={runStyle(run, props.textStyle)}
209 onPress={226 onPress={
210 href227 href
...@@ -217,6 +234,11 @@ export function NativeMarkdownSelectableText(props: {...@@ -217,6 +234,11 @@ export function NativeMarkdownSelectableText(props: {
217 }234 }
218 : undefined235 : undefined
219 }236 }
237 onContextMenuAction={
238 contextMenu && href && menu
239 ? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier)
240 : undefined
241 }
220 >242 >
221 {text}243 {text}
222 </MarkdownTextPrimitive>244 </MarkdownTextPrimitive>
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+46-30
...@@ -9,7 +9,11 @@ import {...@@ -9,7 +9,11 @@ import {
9 nativeMarkdownWithPreservedSoftBreaks,9 nativeMarkdownWithPreservedSoftBreaks,
10} from "./nativeMarkdownText";10} from "./nativeMarkdownText";
11import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";11import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios";
12import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";12import {
13 MarkdownFileContextMenuContext,
14 NativeMarkdownSelectableText,
15 type MarkdownFileContextMenuHandlers,
16} from "./NativeMarkdownSelectableText.ios";
13import type {17import type {
14 SelectableMarkdownSkill,18 SelectableMarkdownSkill,
15 SelectableMarkdownTextProps,19 SelectableMarkdownTextProps,
...@@ -38,6 +42,8 @@ export function SelectableMarkdownText({...@@ -38,6 +42,8 @@ export function SelectableMarkdownText({
38 highlightCode,42 highlightCode,
39 preserveSoftBreaks = false,43 preserveSoftBreaks = false,
40 onLinkPress,44 onLinkPress,
45 fileContextMenu,
46 onFileContextMenuAction,
41 renderImage,47 renderImage,
42 marginTop = 0,48 marginTop = 0,
43 marginBottom = 0,49 marginBottom = 0,
...@@ -61,41 +67,51 @@ export function SelectableMarkdownText({...@@ -61,41 +67,51 @@ export function SelectableMarkdownText({
61 );67 );
62 }, [markdown, preserveSoftBreaks, skills]);68 }, [markdown, preserveSoftBreaks, skills]);
6369
70 const fileContextMenuHandlers = useMemo<MarkdownFileContextMenuHandlers | null>(
71 () =>
72 fileContextMenu && onFileContextMenuAction
73 ? { fileContextMenu, onFileContextMenuAction }
74 : null,
75 [fileContextMenu, onFileContextMenuAction],
76 );
77
64 return (78 return (
65 <MarkdownImageRendererContext.Provider value={renderImage ?? null}>79 <MarkdownImageRendererContext.Provider value={renderImage ?? null}>
66 {/* A percentage width here creates a cyclic intrinsic measurement inside80 <MarkdownFileContextMenuContext.Provider value={fileContextMenuHandlers}>
81 {/* A percentage width here creates a cyclic intrinsic measurement inside
67 shrink-to-fit containers such as user-message bubbles. Yoga then gives82 shrink-to-fit containers such as user-message bubbles. Yoga then gives
68 the native text node an unbounded second pass and the parent only clips83 the native text node an unbounded second pass and the parent only clips
69 the resulting single-line width instead of reflowing it. */}84 the resulting single-line width instead of reflowing it. */}
70 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>85 <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}>
71 {chunks.map((chunk, index) => {86 {chunks.map((chunk, index) => {
72 const content =87 const content =
73 chunk.kind === "rich" ? (88 chunk.kind === "rich" ? (
74 <NativeMarkdownBlock89 <NativeMarkdownBlock
75 node={chunk.node}90 node={chunk.node}
76 skills={skills}91 skills={skills}
77 textStyle={textStyle}92 textStyle={textStyle}
78 highlightCode={highlightCode}93 highlightCode={highlightCode}
79 onLinkPress={onLinkPress}94 onLinkPress={onLinkPress}
80 />95 />
81 ) : (96 ) : (
82 <NativeMarkdownSelectableText97 <NativeMarkdownSelectableText
83 runs={chunk.runs}98 runs={chunk.runs}
84 textStyle={textStyle}99 textStyle={textStyle}
85 onLinkPress={onLinkPress}100 onLinkPress={onLinkPress}
86 />101 />
87 );102 );
88103
89 return (104 return (
90 <View105 <View
91 key={chunk.key}106 key={chunk.key}
92 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}107 style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }}
93 >108 >
94 {content}109 {content}
95 </View>110 </View>
96 );111 );
97 })}112 })}
98 </View>113 </View>
114 </MarkdownFileContextMenuContext.Provider>
99 </MarkdownImageRendererContext.Provider>115 </MarkdownImageRendererContext.Provider>
100 );116 );
101}117}
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+13
...@@ -50,6 +50,17 @@ export interface MarkdownImageRequest {...@@ -50,6 +50,17 @@ export interface MarkdownImageRequest {
50 */50 */
51export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode;51export 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
53export interface SelectableMarkdownTextProps {64export interface SelectableMarkdownTextProps {
54 readonly markdown: string;65 readonly markdown: string;
55 readonly textStyle: NativeMarkdownTextStyle;66 readonly textStyle: NativeMarkdownTextStyle;
...@@ -57,6 +68,8 @@ export interface SelectableMarkdownTextProps {...@@ -57,6 +68,8 @@ export interface SelectableMarkdownTextProps {
57 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;68 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
58 readonly preserveSoftBreaks?: boolean;69 readonly preserveSoftBreaks?: boolean;
59 readonly onLinkPress?: (href: string) => void;70 readonly onLinkPress?: (href: string) => void;
71 readonly fileContextMenu?: (href: string) => MarkdownFileContextMenu | undefined;
72 readonly onFileContextMenuAction?: (href: string, actionId: string) => void;
60 readonly renderImage?: MarkdownImageRenderer;73 readonly renderImage?: MarkdownImageRenderer;
61 readonly marginTop?: number;74 readonly marginTop?: number;
62 readonly marginBottom?: number;75 readonly marginBottom?: number;
apps/mobile/modules/t3-markdown-text/src/T3MarkdownTextRunNativeComponent.ts+6
...@@ -11,6 +11,10 @@ interface TargetedEvent {...@@ -11,6 +11,10 @@ interface TargetedEvent {
11 target: Int32;11 target: Int32;
12}12}
1313
14interface ContextMenuActionEvent extends TargetedEvent {
15 actionIdentifier: string;
16}
17
14type TextDecorationLine = "none" | "underline" | "line-through";18type TextDecorationLine = "none" | "underline" | "line-through";
1519
16type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed";20type TextDecorationStyle = "solid" | "double" | "dotted" | "dashed";
...@@ -42,8 +46,10 @@ interface NativeProps extends ViewProps {...@@ -42,8 +46,10 @@ interface NativeProps extends ViewProps {
42 textDecorationColor?: ColorValue;46 textDecorationColor?: ColorValue;
43 textAlign?: WithDefault<TextAlign, "auto">;47 textAlign?: WithDefault<TextAlign, "auto">;
44 shadowRadius?: WithDefault<Float, 0>;48 shadowRadius?: WithDefault<Float, 0>;
49 contextMenuConfig?: string;
45 onPress?: BubblingEventHandler<TargetedEvent>;50 onPress?: BubblingEventHandler<TargetedEvent>;
46 onLongPress?: BubblingEventHandler<TargetedEvent>;51 onLongPress?: BubblingEventHandler<TargetedEvent>;
52 onContextMenuAction?: BubblingEventHandler<ContextMenuActionEvent>;
47}53}
4854
49export default codegenNativeComponent<NativeProps>("T3MarkdownTextRun", {55export default codegenNativeComponent<NativeProps>("T3MarkdownTextRun", {
apps/mobile/src/features/files/filePath.test.ts+1
...@@ -32,6 +32,7 @@ describe("resolveWorkspaceRelativeFilePath", () => {...@@ -32,6 +32,7 @@ describe("resolveWorkspaceRelativeFilePath", () => {
32 it("rejects paths outside the workspace", () => {32 it("rejects paths outside the workspace", () => {
33 expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull();33 expect(resolveWorkspaceRelativeFilePath("/repo", "/other/main.ts")).toBeNull();
34 expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull();34 expect(resolveWorkspaceRelativeFilePath("/repo", "../other/main.ts")).toBeNull();
35 expect(resolveWorkspaceRelativeFilePath("/repo", "/repo/../outside.txt")).toBeNull();
35 expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull();36 expect(resolveWorkspaceRelativeFilePath(null, "/repo/main.ts")).toBeNull();
36 });37 });
37});38});
apps/mobile/src/features/files/filePath.ts+6-1
...@@ -88,7 +88,12 @@ export function resolveWorkspaceRelativeFilePath(...@@ -88,7 +88,12 @@ export function resolveWorkspaceRelativeFilePath(
88 return null;88 return null;
89 }89 }
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);
92}97}
9398
94export function isVideoPreviewFile(path: string): boolean {99export function isVideoPreviewFile(path: string): boolean {
apps/mobile/src/features/threads/ThreadFeed.tsx+44-10
...@@ -91,6 +91,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";...@@ -91,6 +91,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
91import {91import {
92 hasNativeSelectableMarkdownText,92 hasNativeSelectableMarkdownText,
93 SelectableMarkdownText,93 SelectableMarkdownText,
94 type MarkdownFileContextMenu,
94 type MarkdownImageRenderer,95 type MarkdownImageRenderer,
95 type NativeMarkdownTextStyle,96 type NativeMarkdownTextStyle,
96 type SelectableMarkdownSkill,97 type SelectableMarkdownSkill,
...@@ -176,6 +177,7 @@ import {...@@ -176,6 +177,7 @@ import {
176 resolveWorkspaceRelativeFilePath,177 resolveWorkspaceRelativeFilePath,
177} from "../files/filePath";178} from "../files/filePath";
178import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";179import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize";
180import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
179181
180const WIDE_MARKDOWN_BLOCK_OPTIONS = {182const WIDE_MARKDOWN_BLOCK_OPTIONS = {
181 // Native iOS blockquotes and adjacent selectable text are separate layout183 // Native iOS blockquotes and adjacent selectable text are separate layout
...@@ -872,10 +874,17 @@ function ArtifactTemplateCard(props: {...@@ -872,10 +874,17 @@ function ArtifactTemplateCard(props: {
872 );874 );
873}875}
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
875const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {884const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
876 readonly markdown: string;885 readonly markdown: string;
877 readonly markdownStyles: MarkdownStyleSet;886 readonly markdownStyles: MarkdownStyleSet;
878 readonly onLinkPress: (href: string) => void;887 readonly linkHandlers: MarkdownLinkHandlers;
879 readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;888 readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
880 readonly renderImage: MarkdownImageRenderer;889 readonly renderImage: MarkdownImageRenderer;
881 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;890 readonly skills?: ReadonlyArray<SelectableMarkdownSkill> | undefined;
...@@ -904,7 +913,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {...@@ -904,7 +913,7 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
904 markdown={markdown}913 markdown={markdown}
905 skills={props.skills}914 skills={props.skills}
906 textStyle={props.markdownStyles.nativeTextStyle}915 textStyle={props.markdownStyles.nativeTextStyle}
907 onLinkPress={props.onLinkPress}916 {...props.linkHandlers}
908 renderImage={props.renderImage}917 renderImage={props.renderImage}
909 />918 />
910 ) : (919 ) : (
...@@ -1465,7 +1474,7 @@ function renderFeedEntry(...@@ -1465,7 +1474,7 @@ function renderFeedEntry(
1465 readonly onToggleTurnFold: (turnId: TurnId) => void;1474 readonly onToggleTurnFold: (turnId: TurnId) => void;
1466 readonly onPressPreview: (source: FilePreviewSource) => void;1475 readonly onPressPreview: (source: FilePreviewSource) => void;
1467 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;1476 readonly onPressVideo: (attachment: ChatFileAttachment, sourceIdentifier: string) => void;
1468 readonly onMarkdownLinkPress: (href: string) => void;1477 readonly markdownLinkHandlers: MarkdownLinkHandlers;
1469 readonly renderMarkdownImage: MarkdownImageRenderer;1478 readonly renderMarkdownImage: MarkdownImageRenderer;
1470 readonly renderViewedImage: MarkdownImageRenderer;1479 readonly renderViewedImage: MarkdownImageRenderer;
1471 readonly iconSubtleColor: string | import("react-native").ColorValue;1480 readonly iconSubtleColor: string | import("react-native").ColorValue;
...@@ -1573,7 +1582,7 @@ function renderFeedEntry(...@@ -1573,7 +1582,7 @@ function renderFeedEntry(
1573 markdownStyles={styles}1582 markdownStyles={styles}
1574 reviewCommentColors={props.reviewCommentColors}1583 reviewCommentColors={props.reviewCommentColors}
1575 skills={props.skills}1584 skills={props.skills}
1576 onLinkPress={props.onMarkdownLinkPress}1585 linkHandlers={props.markdownLinkHandlers}
1577 renderImage={props.renderMarkdownImage}1586 renderImage={props.renderMarkdownImage}
1578 />1587 />
1579 ) : null}1588 ) : null}
...@@ -1634,7 +1643,7 @@ function renderFeedEntry(...@@ -1634,7 +1643,7 @@ function renderFeedEntry(
1634 <AssistantMarkdownContent1643 <AssistantMarkdownContent
1635 markdown={renderedText}1644 markdown={renderedText}
1636 markdownStyles={styles}1645 markdownStyles={styles}
1637 onLinkPress={props.onMarkdownLinkPress}1646 linkHandlers={props.markdownLinkHandlers}
1638 onUseArtifactTemplate={props.onUseArtifactTemplate}1647 onUseArtifactTemplate={props.onUseArtifactTemplate}
1639 renderImage={props.renderMarkdownImage}1648 renderImage={props.renderMarkdownImage}
1640 skills={props.skills}1649 skills={props.skills}
...@@ -1704,7 +1713,7 @@ function UserMessageContent(props: {...@@ -1704,7 +1713,7 @@ function UserMessageContent(props: {
1704 readonly markdownStyles: MarkdownStyleSet;1713 readonly markdownStyles: MarkdownStyleSet;
1705 readonly reviewCommentColors: ReviewCommentColors;1714 readonly reviewCommentColors: ReviewCommentColors;
1706 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;1715 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
1707 readonly onLinkPress: (href: string) => void;1716 readonly linkHandlers: MarkdownLinkHandlers;
1708 readonly renderImage: MarkdownImageRenderer;1717 readonly renderImage: MarkdownImageRenderer;
1709}) {1718}) {
1710 const segments = parseReviewCommentMessageSegments(props.text);1719 const segments = parseReviewCommentMessageSegments(props.text);
...@@ -1717,7 +1726,7 @@ function UserMessageContent(props: {...@@ -1717,7 +1726,7 @@ function UserMessageContent(props: {
1717 skills={props.skills}1726 skills={props.skills}
1718 textStyle={props.markdownStyles.nativeTextStyle}1727 textStyle={props.markdownStyles.nativeTextStyle}
1719 preserveSoftBreaks1728 preserveSoftBreaks
1720 onLinkPress={props.onLinkPress}1729 {...props.linkHandlers}
1721 renderImage={props.renderImage}1730 renderImage={props.renderImage}
1722 />1731 />
1723 );1732 );
...@@ -1759,7 +1768,7 @@ function UserMessageContent(props: {...@@ -1759,7 +1768,7 @@ function UserMessageContent(props: {
1759 skills={props.skills}1768 skills={props.skills}
1760 textStyle={props.markdownStyles.nativeTextStyle}1769 textStyle={props.markdownStyles.nativeTextStyle}
1761 preserveSoftBreaks1770 preserveSoftBreaks
1762 onLinkPress={props.onLinkPress}1771 {...props.linkHandlers}
1763 renderImage={props.renderImage}1772 renderImage={props.renderImage}
1764 />1773 />
1765 ) : (1774 ) : (
...@@ -2171,6 +2180,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2171,6 +2180,31 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2171 },2180 },
2172 [props.environmentId, props.threadId, props.workspaceRoot, navigation],2181 [props.environmentId, props.threadId, props.workspaceRoot, navigation],
2173 );2182 );
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 );
2174 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(2208 const renderMarkdownImage = useCallback<MarkdownImageRenderer>(
2175 (image) => {2209 (image) => {
2176 const media = resolveMarkdownMediaPreview(image.href, {2210 const media = resolveMarkdownMediaPreview(image.href, {
...@@ -2696,7 +2730,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2696,7 +2730,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2696 onToggleTurnFold,2730 onToggleTurnFold,
2697 onPressPreview,2731 onPressPreview,
2698 onPressVideo,2732 onPressVideo,
2699 onMarkdownLinkPress,2733 markdownLinkHandlers,
2700 renderMarkdownImage,2734 renderMarkdownImage,
2701 renderViewedImage,2735 renderViewedImage,
2702 iconSubtleColor,2736 iconSubtleColor,
...@@ -2726,7 +2760,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2726,7 +2760,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2726 reviewCommentBubbleWidth,2760 reviewCommentBubbleWidth,
2727 userBubbleMaxWidth,2761 userBubbleMaxWidth,
2728 onCopyWorkRow,2762 onCopyWorkRow,
2729 onMarkdownLinkPress,2763 markdownLinkHandlers,
2730 onPressPreview,2764 onPressPreview,
2731 onPressVideo,2765 onPressVideo,
2732 onToggleTurnFold,2766 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"...@@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter"
8type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;8type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
99
10export type {10export type {
11 MarkdownFileContextMenu,
12 MarkdownFileContextMenuAction,
11 MarkdownImageRenderer,13 MarkdownImageRenderer,
12 MarkdownImageRequest,14 MarkdownImageRequest,
13 NativeMarkdownTextStyle,15 NativeMarkdownTextStyle,
apps/mobile/src/native/SelectableMarkdownText.tsx+2
...@@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/...@@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/
3type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;3type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">;
44
5export type {5export type {
6 MarkdownFileContextMenu,
7 MarkdownFileContextMenuAction,
6 MarkdownImageRenderer,8 MarkdownImageRenderer,
7 MarkdownImageRequest,9 MarkdownImageRequest,
8 NativeMarkdownTextStyle,10 NativeMarkdownTextStyle,
docs/user/composer.md+2-1
...@@ -107,7 +107,8 @@ path** and **Open in file viewer**. These actions are available in expanded prev...@@ -107,7 +107,8 @@ path** and **Open in file viewer**. These actions are available in expanded prev
107On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its107On mobile, touch and hold an inline image or use a preview's **Media actions** menu to see its
108source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file108source, copy the path or URL, or choose **Save or share**. Workspace media can open in the file
109viewer from the same menu. Saving downloads a copy only when you request it; it does not change109viewer 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
112Use Markdown image syntax to embed either kind of media:113Use Markdown image syntax to embed either kind of media:
113114