Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
127 changes: 127 additions & 0 deletions apps/mobile/src/components/agents/chat-link-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as Clipboard from 'expo-clipboard';
import { Share } from 'react-native';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { toast } from 'sonner-native';

import { openExternalUrl } from '@/lib/external-link';

import {
buildChatLinkActionSheet,
getSelectedChatLinkAction,
performChatLinkAction,
} from './chat-link-actions';

vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }));
vi.mock('react-native', () => ({
Share: { share: vi.fn(), dismissedAction: 'dismissedAction', sharedAction: 'sharedAction' },
}));
vi.mock('sonner-native', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
vi.mock('@/lib/external-link', () => ({ openExternalUrl: vi.fn() }));

// Share.share is a jest/vitest mock function assigned via vi.mock above, not a bound instance method.
// eslint-disable-next-line typescript-eslint/unbound-method -- see above
const mockedShare = vi.mocked(Share.share);

describe('chat link actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('orders the approved actions and marks cancel', () => {
const sheet = buildChatLinkActionSheet();

expect(sheet.options).toEqual(['Open link', 'Copy link', 'Share link', 'Cancel']);
expect(sheet.cancelButtonIndex).toBe(3);
expect(getSelectedChatLinkAction(sheet, 0)).toBe('open');
expect(getSelectedChatLinkAction(sheet, 1)).toBe('copy');
expect(getSelectedChatLinkAction(sheet, 2)).toBe('share');
expect(getSelectedChatLinkAction(sheet, 3)).toBeNull();
expect(getSelectedChatLinkAction(sheet, undefined)).toBeNull();
});

it('opens the exact URL through the existing browser helper with retry enabled', async () => {
await performChatLinkAction('open', 'https://kilo.ai/docs?source=chat#links');

expect(openExternalUrl).toHaveBeenCalledWith('https://kilo.ai/docs?source=chat#links', {
label: 'link',
retryOnError: true,
});
});

it('copies the exact URL and confirms success', async () => {
vi.mocked(Clipboard.setStringAsync).mockResolvedValue(true);

await performChatLinkAction('copy', 'https://kilo.ai/docs');

expect(Clipboard.setStringAsync).toHaveBeenCalledWith('https://kilo.ai/docs');
expect(toast.success).toHaveBeenCalledWith('Link copied');
});

it('retries only copying after a clipboard failure', async () => {
vi.mocked(Clipboard.setStringAsync)
.mockRejectedValueOnce(new Error('clipboard unavailable'))
.mockResolvedValueOnce(true);

await performChatLinkAction('copy', 'https://kilo.ai/docs');

expect(toast.error).toHaveBeenCalledWith('Could not copy link', {
action: { label: 'Try again', onClick: expect.any(Function) },
});
const options = vi.mocked(toast.error).mock.calls[0]?.[1];
if (!options?.action || typeof options.action !== 'object' || !('onClick' in options.action)) {
throw new Error('Expected retry action');
}

options.action.onClick();
await vi.waitFor(() => {
expect(Clipboard.setStringAsync).toHaveBeenCalledTimes(2);
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(mockedShare).not.toHaveBeenCalled();
});

it('treats a false clipboard result as a retryable failure', async () => {
vi.mocked(Clipboard.setStringAsync).mockResolvedValue(false);

await performChatLinkAction('copy', 'https://kilo.ai/docs');

expect(toast.success).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith('Could not copy link', {
action: { label: 'Try again', onClick: expect.any(Function) },
});
});

it('shares the exact URL without treating dismissal as failure', async () => {
mockedShare.mockResolvedValue({ action: Share.dismissedAction });

await performChatLinkAction('share', 'https://kilo.ai/docs');

expect(mockedShare).toHaveBeenCalledWith({ message: 'https://kilo.ai/docs' });
expect(toast.error).not.toHaveBeenCalled();
});

it('retries only sharing after a share failure', async () => {
mockedShare
.mockRejectedValueOnce(new Error('share unavailable'))
.mockResolvedValueOnce({ action: Share.sharedAction });

await performChatLinkAction('share', 'https://kilo.ai/docs');

expect(toast.error).toHaveBeenCalledWith('Could not share link', {
action: { label: 'Try again', onClick: expect.any(Function) },
});
const options = vi.mocked(toast.error).mock.calls[0]?.[1];
if (!options?.action || typeof options.action !== 'object' || !('onClick' in options.action)) {
throw new Error('Expected retry action');
}

options.action.onClick();
await vi.waitFor(() => {
expect(mockedShare).toHaveBeenCalledTimes(2);
});
expect(openExternalUrl).not.toHaveBeenCalled();
expect(Clipboard.setStringAsync).not.toHaveBeenCalled();
});
});
78 changes: 78 additions & 0 deletions apps/mobile/src/components/agents/chat-link-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as Clipboard from 'expo-clipboard';
import { Share } from 'react-native';
import { toast } from 'sonner-native';

import { openExternalUrl } from '@/lib/external-link';

type ChatLinkAction = 'open' | 'copy' | 'share';

type ChatLinkActionOption =
| { kind: ChatLinkAction; label: string }
| { kind: 'cancel'; label: 'Cancel' };

export function buildChatLinkActionSheet() {
const actions: ChatLinkActionOption[] = [
{ kind: 'open', label: 'Open link' },
{ kind: 'copy', label: 'Copy link' },
{ kind: 'share', label: 'Share link' },
{ kind: 'cancel', label: 'Cancel' },
];

return {
actions,
options: actions.map(action => action.label),
cancelButtonIndex: actions.length - 1,
};
}

export function getSelectedChatLinkAction(
sheet: ReturnType<typeof buildChatLinkActionSheet>,
index: number | undefined
): ChatLinkAction | null {
if (index === undefined) {
return null;
}
const action = sheet.actions[index];
return action && action.kind !== 'cancel' ? action.kind : null;
}

function showRetryableError(message: string, retry: () => Promise<void>) {
toast.error(message, {
action: {
label: 'Try again',
onClick: () => {
void retry();
},
},
});
}

export async function performChatLinkAction(action: ChatLinkAction, href: string): Promise<void> {
if (action === 'open') {
await openExternalUrl(href, { label: 'link', retryOnError: true });
return;
}

if (action === 'copy') {
try {
const copied = await Clipboard.setStringAsync(href);
if (!copied) {
throw new Error('Clipboard rejected link');
}
toast.success('Link copied');
} catch {
showRetryableError('Could not copy link', async () => {
await performChatLinkAction('copy', href);
});
}
return;
}

try {
await Share.share({ message: href });
} catch {
showRetryableError('Could not share link', async () => {
await performChatLinkAction('share', href);
});
}
}
43 changes: 43 additions & 0 deletions apps/mobile/src/components/agents/chat-markdown-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useActionSheet } from '@expo/react-native-action-sheet';
import { useCallback } from 'react';
import { type GestureResponderEvent } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import {
buildChatLinkActionSheet,
getSelectedChatLinkAction,
performChatLinkAction,
} from './chat-link-actions';
import { MarkdownText, type MarkdownTextProps } from './markdown-text';

type ChatMarkdownTextProps = Omit<MarkdownTextProps, 'onLongPressLink'>;

export function ChatMarkdownText(props: Readonly<ChatMarkdownTextProps>) {
const { showActionSheetWithOptions } = useActionSheet();
const { bottom } = useSafeAreaInsets();

const handleLongPressLink = useCallback(
(href: string, event?: GestureResponderEvent) => {
event?.stopPropagation();
const sheet = buildChatLinkActionSheet();
showActionSheetWithOptions(
{
options: sheet.options,
cancelButtonIndex: sheet.cancelButtonIndex,
title: 'Link actions',
message: href,
containerStyle: { paddingBottom: bottom },
},
index => {
const action = getSelectedChatLinkAction(sheet, index);
if (action) {
void performChatLinkAction(action, href);
}
}
);
},
[bottom, showActionSheetWithOptions]
);

return <MarkdownText {...props} onLongPressLink={handleLongPressLink} />;
}
27 changes: 26 additions & 1 deletion apps/mobile/src/components/agents/markdown-link.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest';

import { resolveLinkAccessibilityLabel } from './markdown-link';
import {
getLinkAccessibilityActions,
getLinkLongPressHandler,
LINK_ACCESSIBILITY_HINT,
resolveLinkAccessibilityLabel,
} from './markdown-link';

const onLongPressLink = () => undefined;

describe('resolveLinkAccessibilityLabel', () => {
it('prefers an explicit title', () => {
Expand All @@ -25,3 +32,21 @@ describe('resolveLinkAccessibilityLabel', () => {
expect(resolveLinkAccessibilityLabel([], 'mailto:hello@kilo.ai')).toBe('mailto:hello@kilo.ai');
});
});

describe('link action accessibility', () => {
it('describes the existing in-app browser behavior', () => {
expect(LINK_ACCESSIBILITY_HINT).toBe('Opens in browser');
});

it('exposes link actions only when the chat callback is enabled', () => {
expect(getLinkAccessibilityActions(false)).toBeUndefined();
expect(getLinkAccessibilityActions(true)).toEqual([
{ name: 'showLinkActions', label: 'Show link actions' },
]);
});

it('attaches a long-press handler only when chat link actions are enabled', () => {
expect(getLinkLongPressHandler(undefined, 'https://kilo.ai')).toBeUndefined();
expect(getLinkLongPressHandler(onLongPressLink, 'https://kilo.ai')).toBeTypeOf('function');
});
});
20 changes: 20 additions & 0 deletions apps/mobile/src/components/agents/markdown-link.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ReactNode } from 'react';
import { type AccessibilityActionInfo, type GestureResponderEvent } from 'react-native';

const URL_HOST_PATTERN = /^[a-z][a-z\d+.-]*:\/\/([^/?#]+)/i;

Expand All @@ -20,3 +21,22 @@ export function resolveLinkAccessibilityLabel(
}
return getUrlHost(href) ?? href;
}

export const LINK_ACCESSIBILITY_HINT = 'Opens in browser';

export function getLinkAccessibilityActions(
enabled: boolean
): AccessibilityActionInfo[] | undefined {
return enabled ? [{ name: 'showLinkActions', label: 'Show link actions' }] : undefined;
}

export function getLinkLongPressHandler(
onLongPressLink: ((href: string, event?: GestureResponderEvent) => void) | undefined,
href: string
): ((event: GestureResponderEvent) => void) | undefined {
return onLongPressLink
? event => {
onLongPressLink(href, event);
}
: undefined;
}
Loading