Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/modules/t3-markdown-text/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export {
type MarkdownHighlightedToken,
} from "./src/SelectableMarkdownText";
export type {
MarkdownFileContextMenu,
MarkdownFileContextMenuAction,
NativeMarkdownTextStyle,
SelectableMarkdownSkill,
SelectableMarkdownTextProps,
Expand Down
101 changes: 92 additions & 9 deletions apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ @implementation T3MarkdownText {
BOOL _suppressSelectionChange;
NSMutableDictionary<NSString *, UIImage *> * _attachmentImages;
NSMutableSet<NSString *> * _pendingAttachmentUris;
UILongPressGestureRecognizer *_longPressGestureRecognizer;
UITapGestureRecognizer *_pressGestureRecognizer;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider
Expand All @@ -223,21 +225,24 @@ - (instancetype)initWithFrame:(CGRect)frame
_textView.textContainerInset = UIEdgeInsetsZero;
_textView.textContainer.lineFragmentPadding = 0;
_textView.delegate = self;
// Chat text supports selection and contextual actions, but not drag-and-drop.
_textView.textDragInteraction.enabled = NO;
_textView.linkTextAttributes = @{};
// Must match RCTTextLayoutManager, which measures with usesFontLeading = NO.
_textView.layoutManager.usesFontLeading = NO;
[self addSubview:_textView];

const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(handleLongPressIfNecessary:)];
longPressGestureRecognizer.delegate = self;
_longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(handleLongPressIfNecessary:)];
_longPressGestureRecognizer.delegate = self;

const auto pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(handlePressIfNecessary:)];
pressGestureRecognizer.delegate = self;
[pressGestureRecognizer requireGestureRecognizerToFail:longPressGestureRecognizer];
_pressGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(handlePressIfNecessary:)];
_pressGestureRecognizer.delegate = self;
[_pressGestureRecognizer requireGestureRecognizerToFail:_longPressGestureRecognizer];

[_textView addGestureRecognizer:pressGestureRecognizer];
[_textView addGestureRecognizer:longPressGestureRecognizer];
[_textView addGestureRecognizer:_pressGestureRecognizer];
[_textView addGestureRecognizer:_longPressGestureRecognizer];
}

return self;
Expand Down Expand Up @@ -312,6 +317,26 @@ - (void)drawRect:(CGRect)rect
convertedAttrString,
_state->getData().attachmentRanges,
_attachmentImages);
NSUInteger runLocation = 0;
for (UIView *child in self.subviews) {
if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
continue;
}

T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child;
const NSRange runRange = NSMakeRange(runLocation, textChild.text.length);
runLocation = NSMaxRange(runRange);
if (![textChild hasContextMenu] || runRange.length == 0 ||
NSMaxRange(runRange) > convertedAttrString.length) {
continue;
}

NSURL *link = [NSURL URLWithString:
[NSString stringWithFormat:@"t3-markdown-run://%ld", (long)textChild.tag]];
if (link != nil) {
[convertedAttrString addAttribute:NSLinkAttributeName value:link range:runRange];
}
}
[self loadAttachmentImages:_state->getData().attachmentRanges];

// Setting attributedText clears any active text selection, and re-assigning
Expand Down Expand Up @@ -484,6 +509,18 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecogni
return YES;
}

- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
{
if (gestureRecognizer != _longPressGestureRecognizer &&
gestureRecognizer != _pressGestureRecognizer) {
return YES;
}

const auto location = [self getLocationOfPress:gestureRecognizer];
const auto child = [self getTouchChild:location];
return ![child hasContextMenu];
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
{
return YES;
Expand All @@ -507,6 +544,24 @@ - (void)clearSelectionForOutsideTapWithHitView:(UIView *)hitView

// MARK: - Touch handling

- (nullable T3MarkdownTextRun *)childForCharacterRange:(NSRange)characterRange
{
NSUInteger location = 0;
for (UIView *child in self.subviews) {
if (![child isKindOfClass:[T3MarkdownTextRun class]]) {
continue;
}

T3MarkdownTextRun *textChild = (T3MarkdownTextRun *)child;
const NSRange range = NSMakeRange(location, textChild.text.length);
if (NSIntersectionRange(range, characterRange).length > 0) {
return textChild;
}
location = NSMaxRange(range);
}
return nil;
}

- (CGPoint)getLocationOfPress:(UIGestureRecognizer*)sender
{
return [sender locationInView:_textView];
Expand Down Expand Up @@ -550,6 +605,10 @@ - (void)handlePressIfNecessary:(UITapGestureRecognizer*)sender

- (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender
{
if (sender.state != UIGestureRecognizerStateBegan) {
return;
}

const auto location = [self getLocationOfPress:sender];
const auto child = [self getTouchChild:location];

Expand All @@ -560,6 +619,30 @@ - (void)handleLongPressIfNecessary:(UILongPressGestureRecognizer*)sender

// MARK: - UITextViewDelegate

- (nullable UIAction *)textView:(UITextView *)textView
primaryActionForTextItem:(UITextItem *)textItem
defaultAction:(UIAction *)defaultAction API_AVAILABLE(ios(17.0))
{
T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range];
if (![child hasContextMenu]) {
return defaultAction;
}

__weak T3MarkdownTextRun *weakChild = child;
return [UIAction actionWithHandler:^(__kindof UIAction *action) {
[weakChild onPress];
}];
}

- (nullable UITextItemMenuConfiguration *)textView:(UITextView *)textView
menuConfigurationForTextItem:(UITextItem *)textItem
defaultMenu:(UIMenu *)defaultMenu API_AVAILABLE(ios(17.0))
{
T3MarkdownTextRun *child = [self childForCharacterRange:textItem.range];
UIMenu *menu = [child contextMenu];
return [UITextItemMenuConfiguration configurationWithMenu:menu ?: defaultMenu];
}

- (void)textViewDidChangeSelection:(UITextView *)textView
{
if (_suppressSelectionChange) {
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ NS_ASSUME_NONNULL_BEGIN

@property (nonatomic, copy, nullable) NSString *text;

- (nullable UIMenu *)contextMenu;
- (BOOL)hasContextMenu;
- (void)onContextMenuAction:(NSString *)actionIdentifier;
- (void)onPress;
- (void)onLongPress;

Expand Down
72 changes: 70 additions & 2 deletions apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextRun.mm
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ @interface T3MarkdownTextRun () <RCTT3MarkdownTextRunViewProtocol>

@implementation T3MarkdownTextRun {
NSString * _text;
RCTBubblingEventBlock _onPress;
RCTBubblingEventBlock _onLongPress;
NSString * _contextMenuConfig;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider
Expand All @@ -43,9 +42,78 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
_text = text;
}

if (newViewProps.contextMenuConfig != oldViewProps.contextMenuConfig) {
_contextMenuConfig = [NSString stringWithUTF8String:newViewProps.contextMenuConfig.c_str()];
}

[super updateProps:props oldProps:oldProps];
}

- (BOOL)hasContextMenu
{
return _contextMenuConfig.length > 0;
}

- (nullable UIMenu *)contextMenu
{
if (_contextMenuConfig.length == 0) {
return nil;
}

NSData *data = [_contextMenuConfig dataUsingEncoding:NSUTF8StringEncoding];
NSDictionary *config = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (![config isKindOfClass:[NSDictionary class]]) {
return nil;
}

NSArray *actionConfigs = config[@"actions"];
if (![actionConfigs isKindOfClass:[NSArray class]] || actionConfigs.count == 0) {
return nil;
}

NSMutableArray<UIMenuElement *> *actions = [NSMutableArray arrayWithCapacity:actionConfigs.count];
__weak T3MarkdownTextRun *weakSelf = self;
for (NSDictionary *actionConfig in actionConfigs) {
if (![actionConfig isKindOfClass:[NSDictionary class]]) {
continue;
}
NSString *actionIdentifier = actionConfig[@"id"];
NSString *title = actionConfig[@"title"];
if (![actionIdentifier isKindOfClass:[NSString class]] ||
![title isKindOfClass:[NSString class]]) {
continue;
}

UIAction *action = [UIAction actionWithTitle:title
image:nil
identifier:actionIdentifier
handler:^(__kindof UIAction *selectedAction) {
[weakSelf onContextMenuAction:selectedAction.identifier];
}];
if ([actionConfig[@"disabled"] boolValue]) {
action.attributes = UIMenuElementAttributesDisabled;
}
[actions addObject:action];
}

if (actions.count == 0) {
return nil;
}
NSString *title = [config[@"title"] isKindOfClass:[NSString class]] ? config[@"title"] : @"";
return [UIMenu menuWithTitle:title children:actions];
}

- (void)onContextMenuAction:(NSString *)actionIdentifier
{
if (_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)
->onContextMenuAction(facebook::react::T3MarkdownTextRunEventEmitter::OnContextMenuAction{
static_cast<int>(self.tag),
actionIdentifier.UTF8String,
});
}
}

- (void)onPress {
if (_eventEmitter != nullptr) {
std::dynamic_pointer_cast<const facebook::react::T3MarkdownTextRunEventEmitter>(_eventEmitter)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ export type SelectionChangeEvent = {
nativeEvent: { target: number; start: number; end: number };
};

export type ContextMenuActionEvent = {
nativeEvent: { target: number; actionIdentifier: string };
};

export type MarkdownTextPrimitiveProps = TextProps & {
uiTextView?: boolean;
contextMenuConfig?: string;
onContextMenuAction?: (event: ContextMenuActionEvent) => void;
/**
* Fired when the native text selection changes. Only fires on iOS when
* `uiTextView` is true. Note: fires on every selection-edge adjustment
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
import { createContext, useContext } from "react";
import { Image, Linking, type TextStyle, useColorScheme } from "react-native";

import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
import { markdownFileIconSource } from "./markdownFileIcons";
import type { NativeMarkdownTextRun } from "./nativeMarkdownText";
import type { NativeMarkdownTextStyle } from "./SelectableMarkdownText.types";
import type {
MarkdownFileContextMenu,
NativeMarkdownTextStyle,
} from "./SelectableMarkdownText.types";

export interface MarkdownFileContextMenuHandlers {
readonly fileContextMenu: (href: string) => MarkdownFileContextMenu | undefined;
readonly onFileContextMenuAction: (href: string, actionId: string) => void;
}

/** Set by SelectableMarkdownText so file chips anywhere in the block tree get the same menu. */
export const MarkdownFileContextMenuContext = createContext<MarkdownFileContextMenuHandlers | null>(
null,
);

const EXTERNAL_LINK_PREFIX = "◉ ";
const INLINE_ATTACHMENT_PREFIX = "\uFFFC\u00A0";
Expand Down Expand Up @@ -139,6 +153,7 @@ export function NativeMarkdownSelectableText(props: {
readonly onLinkPress?: (href: string) => void;
}) {
const colorScheme = useColorScheme();
const menu = useContext(MarkdownFileContextMenuContext);
const occurrences = new Map<string, number>();
const prefixedExternalLinks = new Set<string>();
const keyedRuns = props.runs.map((run) => {
Expand Down Expand Up @@ -195,6 +210,7 @@ export function NativeMarkdownSelectableText(props: {
>
{keyedRuns.map(({ key, run, text }) => {
const href = run.href;
const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined;
return (
<MarkdownTextPrimitive
key={key}
Expand All @@ -205,6 +221,7 @@ export function NativeMarkdownSelectableText(props: {
? "t3-skill:sf:cube"
: undefined
}
contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined}
style={runStyle(run, props.textStyle)}
onPress={
href
Expand All @@ -217,6 +234,11 @@ export function NativeMarkdownSelectableText(props: {
}
: undefined
}
onContextMenuAction={
contextMenu && href && menu
? (event) => menu.onFileContextMenuAction(href, event.nativeEvent.actionIdentifier)
: undefined
}
>
{text}
</MarkdownTextPrimitive>
Expand Down
Loading
Loading