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. + } +}