Skip to content
Open
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
38 changes: 38 additions & 0 deletions __tests__/rntl/screens/ChatsListScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,12 @@ jest.mock('react-native-gesture-handler/Swipeable', () => {
};
});

jest.mock('../../../src/utils/exportConversation', () => ({
shareConversationAsText: jest.fn(() => Promise.resolve()),
}));

import { ChatsListScreen } from '../../../src/screens/ChatsListScreen';
import { shareConversationAsText } from '../../../src/utils/exportConversation';

describe('ChatsListScreen', () => {
beforeEach(() => {
Expand Down Expand Up @@ -574,4 +579,37 @@ describe('ChatsListScreen', () => {
}
});
});

// ==========================================================================
// Export Chat Flow
// ==========================================================================
describe('export chat flow', () => {
it('shares the conversation as text when the export swipe action is pressed', () => {
const conv = createConversation({ title: 'Export Me' });
useChatStore.setState({ conversations: [conv] });
useAppStore.setState({
generatedImages: [],
});

const { getByTestId } = render(<ChatsListScreen />);
fireEvent.press(getByTestId('export-conversation-button'));

expect(shareConversationAsText).toHaveBeenCalledWith(conv);
expect(shareConversationAsText).toHaveBeenCalledTimes(1);
});

it('does not show the delete confirmation when export is pressed', () => {
const conv = createConversation({ title: 'Export Only' });
useChatStore.setState({ conversations: [conv] });
useAppStore.setState({
generatedImages: [],
});

const { getByTestId } = render(<ChatsListScreen />);
mockShowAlert.mockClear();
fireEvent.press(getByTestId('export-conversation-button'));

expect(mockShowAlert).not.toHaveBeenCalled();
});
});
});
176 changes: 176 additions & 0 deletions __tests__/unit/utils/exportConversation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Share } from 'react-native';
import RNShare from 'react-native-share';
import RNFS from 'react-native-fs';
import { formatConversationAsText, shareConversationAsText } from '../../../src/utils/exportConversation';
import { Conversation, Message } from '../../../src/types';

function makeMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'msg-1',
role: 'user',
content: 'Hello there',
timestamp: 1750000000000,
...overrides,
};
}

function makeConversation(overrides: Partial<Conversation> = {}): Conversation {
return {
id: 'conv-1',
title: 'Trip planning',
modelId: 'llama-3-8b',
messages: [makeMessage()],
createdAt: '2026-01-01T10:00:00.000Z',
updatedAt: '2026-01-02T12:30:00.000Z',
...overrides,
};
}

describe('formatConversationAsText', () => {
it('includes title, model, created and updated in the header', () => {
const text = formatConversationAsText(makeConversation());
expect(text).toContain('Trip planning');
expect(text).toContain('Model: llama-3-8b');
expect(text).toContain(`Created: ${new Date('2026-01-01T10:00:00.000Z').toLocaleString()}`);
expect(text).toContain(`Updated: ${new Date('2026-01-02T12:30:00.000Z').toLocaleString()}`);
});

it('renders only the header (with trailing newline) when there are no messages', () => {
const text = formatConversationAsText(makeConversation({ messages: [] }));
expect(text.endsWith('\n')).toBe(true);
expect(text).not.toContain('You:');
});

it('labels each role correctly', () => {
const conversation = makeConversation({
messages: [
makeMessage({ id: '1', role: 'user', content: 'hi' }),
makeMessage({ id: '2', role: 'assistant', content: 'hello' }),
makeMessage({ id: '3', role: 'system', content: 'sys note' }),
makeMessage({ id: '4', role: 'tool', content: 'tool output' }),
],
});
const text = formatConversationAsText(conversation);
expect(text).toContain('You:\nhi');
expect(text).toContain('Assistant:\nhello');
expect(text).toContain('System:\nsys note');
expect(text).toContain('Tool:\ntool output');
});

it('omits the attachment note when a message has no attachments field', () => {
const text = formatConversationAsText(makeConversation({
messages: [makeMessage({ attachments: undefined })],
}));
expect(text).not.toContain('attachment');
});

it('omits the attachment note when attachments is an empty array', () => {
const text = formatConversationAsText(makeConversation({
messages: [makeMessage({ attachments: [] })],
}));
expect(text).not.toContain('attachment');
});

it('uses singular "attachment" for exactly one attachment', () => {
const text = formatConversationAsText(makeConversation({
messages: [makeMessage({ attachments: [{ type: 'image', uri: 'file://a.png' } as any] })],
}));
expect(text).toContain('[1 attachment]');
});

it('uses plural "attachments" for more than one attachment', () => {
const text = formatConversationAsText(makeConversation({
messages: [makeMessage({
attachments: [
{ type: 'image', uri: 'file://a.png' } as any,
{ type: 'image', uri: 'file://b.png' } as any,
],
})],
}));
expect(text).toContain('[2 attachments]');
});

it('joins multiple messages with a blank line between them', () => {
const text = formatConversationAsText(makeConversation({
messages: [
makeMessage({ id: '1', content: 'first' }),
makeMessage({ id: '2', content: 'second' }),
],
}));
expect(text).toContain('first\n\n[');
expect(text).toContain('second');
});
});

describe('shareConversationAsText', () => {
let shareSpy: jest.SpiedFunction<typeof Share.share>;
const openSpy = RNShare.open as jest.Mock;
const writeFileSpy = RNFS.writeFile as jest.Mock;

beforeEach(() => {
shareSpy = jest.spyOn(Share, 'share').mockResolvedValue({ action: 'sharedAction' } as any);
openSpy.mockReset().mockResolvedValue({ success: true, message: '' });
writeFileSpy.mockReset().mockResolvedValue(undefined);
});

afterEach(() => {
shareSpy.mockRestore();
});

it('uses the inline Share sheet for a short conversation', async () => {
await shareConversationAsText(makeConversation());
expect(shareSpy).toHaveBeenCalledWith({
message: expect.stringContaining('Trip planning'),
title: 'Trip planning',
});
expect(writeFileSpy).not.toHaveBeenCalled();
expect(openSpy).not.toHaveBeenCalled();
});

it('writes a .txt file and opens the native share sheet for a long conversation', async () => {
const longMessages = Array.from({ length: 20 }, (_, i) =>
makeMessage({ id: `m${i}`, content: `line ${i}` }));
await shareConversationAsText(makeConversation({ title: 'Long Chat!!', messages: longMessages }));

expect(writeFileSpy).toHaveBeenCalledWith(
'/mock/caches/Long_Chat.txt',
expect.stringContaining('line 0'),
'utf8',
);
expect(openSpy).toHaveBeenCalledWith({
url: 'file:///mock/caches/Long_Chat.txt',
type: 'text/plain',
filename: 'Long_Chat',
title: 'Long Chat!!',
failOnCancel: false,
});
expect(shareSpy).not.toHaveBeenCalled();
});

it('falls back to "conversation" as the file name when the title sanitizes to empty', async () => {
const longMessages = Array.from({ length: 20 }, (_, i) =>
makeMessage({ id: `m${i}`, content: `line ${i}` }));
await shareConversationAsText(makeConversation({ title: '!!! ***', messages: longMessages }));

expect(writeFileSpy).toHaveBeenCalledWith(
'/mock/caches/conversation.txt',
expect.any(String),
'utf8',
);
expect(openSpy).toHaveBeenCalledWith(expect.objectContaining({ filename: 'conversation' }));
});

it('swallows errors from the inline share sheet (e.g. user cancelled)', async () => {
shareSpy.mockRejectedValue(new Error('User did not share'));
await expect(shareConversationAsText(makeConversation())).resolves.toBeUndefined();
});

it('swallows errors from the file-based share path', async () => {
const longMessages = Array.from({ length: 20 }, (_, i) =>
makeMessage({ id: `m${i}`, content: `line ${i}` }));
writeFileSpy.mockRejectedValue(new Error('disk full'));
await expect(
shareConversationAsText(makeConversation({ messages: longMessages })),
).resolves.toBeUndefined();
});
});
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ module.exports = {
// legacy files have their NEW branches covered by the suites but aren't whole-file-100%.
'./src/utils/imageModelIntegrity.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
'./src/utils/imageGenAdvice.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
'./src/utils/exportConversation.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
'./src/services/modelLoadErrors.ts': { statements: 100, branches: 100, functions: 100, lines: 100 },
'./src/components/ImageGenAdviceCard.tsx': { statements: 100, branches: 100, functions: 100, lines: 100 },
},
Expand Down
10 changes: 10 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,16 @@ jest.mock('react-native-fs', () => ({
hash: jest.fn(() => Promise.resolve('mockhash')),
}));

// react-native-share mock
jest.mock('react-native-share', () => ({
__esModule: true,
default: {
open: jest.fn(() => Promise.resolve({ success: true, message: '' })),
shareSingle: jest.fn(() => Promise.resolve({ success: true, message: '' })),
isPackageInstalled: jest.fn(() => Promise.resolve({ isInstalled: false })),
},
}));

// react-native-device-info mock
jest.mock('react-native-device-info', () => ({
getTotalMemory: jest.fn(() => Promise.resolve(8 * 1024 * 1024 * 1024)), // 8GB
Expand Down
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"react-native-reanimated": "^4.2.1",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.20.0",
"react-native-share": "^12.3.1",
"react-native-spotlight-tour": "^4.0.0",
"react-native-svg": "^15.15.3",
"react-native-url-polyfill": "^3.0.0",
Expand Down
36 changes: 29 additions & 7 deletions src/screens/ChatsListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useChatStore, useProjectStore, useAppStore } from '../stores';
import { useActiveTextModel } from '../hooks/useActiveTextModel';
import { onnxImageGeneratorService, activeModelService, llmService, remoteServerManager } from '../services';
import { loadModelWithOverride } from '../services/loadModelWithOverride';
import { shareConversationAsText } from '../utils/exportConversation';
import { Conversation } from '../types';
import { RootStackParamList, MainTabParamList } from '../navigation/types';
type NavigationProp = CompositeNavigationProp<
Expand Down Expand Up @@ -162,12 +163,22 @@ export const ChatsListScreen: React.FC = () => {
};

const renderRightActions = (conversation: Conversation) => (
<TouchableOpacity
style={styles.deleteAction}
onPress={() => handleDeleteChat(conversation)}
>
<Icon name="trash-2" size={16} color={colors.error} />
</TouchableOpacity>
<View style={styles.rightActions}>
<TouchableOpacity
style={styles.exportAction}
onPress={() => shareConversationAsText(conversation)}
testID="export-conversation-button"
>
<Icon name="share-2" size={16} color={colors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteAction}
onPress={() => handleDeleteChat(conversation)}
testID="delete-conversation-button"
>
<Icon name="trash-2" size={16} color={colors.error} />
</TouchableOpacity>
</View>
);
Comment on lines 165 to 182

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add accessibilityLabel to the export button.

The export TouchableOpacity is icon-only with no accessibilityLabel. Screen reader users can't identify the action. The existing delete button has the same gap, but new code should set the label.

♿ Proposed accessibility fix
       <TouchableOpacity
         style={styles.exportAction}
         onPress={() => shareConversationAsText(conversation)}
         testID="export-conversation-button"
+        accessibilityLabel="Export conversation"
+        accessibilityRole="button"
       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const renderRightActions = (conversation: Conversation) => (
<TouchableOpacity
style={styles.deleteAction}
onPress={() => handleDeleteChat(conversation)}
>
<Icon name="trash-2" size={16} color={colors.error} />
</TouchableOpacity>
<View style={styles.rightActions}>
<TouchableOpacity
style={styles.exportAction}
onPress={() => shareConversationAsText(conversation)}
testID="export-conversation-button"
>
<Icon name="share-2" size={16} color={colors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteAction}
onPress={() => handleDeleteChat(conversation)}
testID="delete-conversation-button"
>
<Icon name="trash-2" size={16} color={colors.error} />
</TouchableOpacity>
</View>
);
const renderRightActions = (conversation: Conversation) => (
<View style={styles.rightActions}>
<TouchableOpacity
style={styles.exportAction}
onPress={() => shareConversationAsText(conversation)}
testID="export-conversation-button"
accessibilityLabel="Export conversation"
accessibilityRole="button"
>
<Icon name="share-2" size={16} color={colors.primary} />
</TouchableOpacity>
<TouchableOpacity
style={styles.deleteAction}
onPress={() => handleDeleteChat(conversation)}
testID="delete-conversation-button"
>
<Icon name="trash-2" size={16} color={colors.error} />
</TouchableOpacity>
</View>
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/screens/ChatsListScreen.tsx` around lines 165 - 182, Add an
accessibilityLabel to the export TouchableOpacity in renderRightActions, clearly
identifying it as the action for exporting or sharing the conversation. Leave
the existing behavior and delete button unchanged.


const renderChat = ({ item, index }: { item: Conversation; index: number }) => {
Expand Down Expand Up @@ -414,13 +425,24 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
...TYPOGRAPHY.body,
color: colors.primary,
},
rightActions: {
flexDirection: 'row' as const,
marginBottom: SPACING.md,
},
exportAction: {
backgroundColor: colors.surfaceLight,
justifyContent: 'center' as const,
alignItems: 'center' as const,
width: 44,
borderRadius: 10,
marginLeft: SPACING.sm,
},
deleteAction: {
backgroundColor: colors.errorBackground,
justifyContent: 'center' as const,
alignItems: 'center' as const,
width: 44,
borderRadius: 10,
marginBottom: SPACING.md,
marginLeft: SPACING.sm,
},
});
Loading