diff --git a/__tests__/rntl/screens/ChatsListScreen.test.tsx b/__tests__/rntl/screens/ChatsListScreen.test.tsx
index fd782645e..d2bfba0d7 100644
--- a/__tests__/rntl/screens/ChatsListScreen.test.tsx
+++ b/__tests__/rntl/screens/ChatsListScreen.test.tsx
@@ -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(() => {
@@ -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();
+ 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();
+ mockShowAlert.mockClear();
+ fireEvent.press(getByTestId('export-conversation-button'));
+
+ expect(mockShowAlert).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/__tests__/unit/utils/exportConversation.test.ts b/__tests__/unit/utils/exportConversation.test.ts
new file mode 100644
index 000000000..eb15c7fa4
--- /dev/null
+++ b/__tests__/unit/utils/exportConversation.test.ts
@@ -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 {
+ return {
+ id: 'msg-1',
+ role: 'user',
+ content: 'Hello there',
+ timestamp: 1750000000000,
+ ...overrides,
+ };
+}
+
+function makeConversation(overrides: Partial = {}): 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;
+ 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();
+ });
+});
diff --git a/jest.config.js b/jest.config.js
index 6d6537506..f60a6173f 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -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 },
},
diff --git a/jest.setup.ts b/jest.setup.ts
index 6fef787f7..9f6c6daa6 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 8d4467b3b..ca7ffbf3e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -56,6 +56,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",
@@ -13433,6 +13434,15 @@
"react-native": "*"
}
},
+ "node_modules/react-native-share": {
+ "version": "12.3.1",
+ "resolved": "https://registry.npmjs.org/react-native-share/-/react-native-share-12.3.1.tgz",
+ "integrity": "sha512-mRVRie0qKbtj+jWqJ2POPkeSd8SxnMp6aazFZVmq8ZbkUGtQLENR/L58ky8zH4VUEF/WYRmdBiBHWHVtzOewtQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/react-native-spotlight-tour": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/react-native-spotlight-tour/-/react-native-spotlight-tour-4.0.0.tgz",
@@ -15234,7 +15244,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
diff --git a/package.json b/package.json
index 22137a2eb..0dfaa9d54 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/screens/ChatsListScreen.tsx b/src/screens/ChatsListScreen.tsx
index b932599f3..be38b75b5 100644
--- a/src/screens/ChatsListScreen.tsx
+++ b/src/screens/ChatsListScreen.tsx
@@ -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<
@@ -162,12 +163,22 @@ export const ChatsListScreen: React.FC = () => {
};
const renderRightActions = (conversation: Conversation) => (
- handleDeleteChat(conversation)}
- >
-
-
+
+ shareConversationAsText(conversation)}
+ testID="export-conversation-button"
+ >
+
+
+ handleDeleteChat(conversation)}
+ testID="delete-conversation-button"
+ >
+
+
+
);
const renderChat = ({ item, index }: { item: Conversation; index: number }) => {
@@ -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,
},
});
diff --git a/src/utils/exportConversation.ts b/src/utils/exportConversation.ts
new file mode 100644
index 000000000..d0d25a502
--- /dev/null
+++ b/src/utils/exportConversation.ts
@@ -0,0 +1,84 @@
+import { Share } from 'react-native';
+import RNShare from 'react-native-share';
+import RNFS from 'react-native-fs';
+import { Conversation, Message } from '../types';
+
+/** Above this many lines, the transcript is shared as a .txt file instead of inline text. */
+const FILE_SHARE_LINE_THRESHOLD = 30;
+
+function sanitizeFileName(title: string): string {
+ const trimmed = title.trim().replace(/[^a-zA-Z0-9-_ ]/g, '').trim();
+ return trimmed.length > 0 ? trimmed.replace(/\s+/g, '_') : 'conversation';
+}
+
+function roleLabel(role: Message['role']): string {
+ switch (role) {
+ case 'user':
+ return 'You';
+ case 'assistant':
+ return 'Assistant';
+ case 'system':
+ return 'System';
+ case 'tool':
+ return 'Tool';
+ }
+}
+
+function formatMessage(message: Message): string {
+ const time = new Date(message.timestamp).toLocaleString();
+ const attachmentCount = message.attachments?.length ?? 0;
+ const attachmentNote = attachmentCount > 0
+ ? ` [${attachmentCount} attachment${attachmentCount > 1 ? 's' : ''}]`
+ : '';
+ return `[${time}] ${roleLabel(message.role)}:${attachmentNote}\n${message.content}`;
+}
+
+/** Pure serializer: a Conversation -> a plain-text transcript. No I/O. */
+export function formatConversationAsText(conversation: Conversation): string {
+ const header = [
+ conversation.title,
+ `Model: ${conversation.modelId}`,
+ `Created: ${new Date(conversation.createdAt).toLocaleString()}`,
+ `Updated: ${new Date(conversation.updatedAt).toLocaleString()}`,
+ ].join('\n');
+
+ if (conversation.messages.length === 0) {
+ return `${header}\n`;
+ }
+
+ const body = conversation.messages.map(formatMessage).join('\n\n');
+ return `${header}\n\n${body}\n`;
+}
+
+/**
+ * Thin I/O wrapper: hands the transcript to the native share sheet, as a file for long chats.
+ *
+ * React Native's built-in `Share` module can't deliver file attachments on Android at all —
+ * it silently drops the `url` field and sends only `message`/`title` to the native intent.
+ * `react-native-share` owns the file-attachment path on both platforms (it wraps the file in a
+ * content:// URI via its own FileProvider on Android, and an activity item on iOS), so long
+ * chats route through it instead of the core `Share` API.
+ */
+export async function shareConversationAsText(conversation: Conversation): Promise {
+ const transcript = formatConversationAsText(conversation);
+ try {
+ if (transcript.split('\n').length > FILE_SHARE_LINE_THRESHOLD) {
+ const filePath = `${RNFS.CachesDirectoryPath}/${sanitizeFileName(conversation.title)}.txt`;
+ await RNFS.writeFile(filePath, transcript, 'utf8');
+ await RNShare.open({
+ url: `file://${filePath}`,
+ type: 'text/plain',
+ filename: sanitizeFileName(conversation.title),
+ title: conversation.title,
+ failOnCancel: false,
+ });
+ return;
+ }
+ await Share.share({
+ message: transcript,
+ title: conversation.title,
+ });
+ } catch {
+ // User cancelled or the share sheet failed to open — nothing to recover.
+ }
+}