Skip to content

feat(chats): export conversation as a plain-text transcript#531

Open
ferreus wants to merge 1 commit into
off-grid-ai:mainfrom
ferreus:feat/export-conversation-text
Open

feat(chats): export conversation as a plain-text transcript#531
ferreus wants to merge 1 commit into
off-grid-ai:mainfrom
ferreus:feat/export-conversation-text

Conversation

@ferreus

@ferreus ferreus commented Jul 12, 2026

Copy link
Copy Markdown

Adds a "share" swipe action next to delete on the Chats list, letting users get a conversation out of the app as readable text via the native share sheet.

  • src/utils/exportConversation.ts: pure formatConversationAsText() transcript serializer + shareConversationAsText() wrapper. Short transcripts go through RN's core Share sheet; long transcripts are written to a .txt file and handed to react-native-share, since core Share silently drops file attachments on Android.
  • ChatsListScreen: export swipe action (share-2 icon) alongside the existing delete action.
  • 100% unit coverage on the new formatter/share module, plus integration tests on ChatsListScreen for the wiring.

Summary

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (code change that neither fixes a bug nor adds a feature)
  • Chore (build process, CI, dependency updates, etc.)

Screenshots / Screen Recordings

Android

Before After

iOS

Before After

Checklist

General

  • My code follows the project's coding style and conventions
  • I have performed a self-review of my code
  • I have added/updated comments where the logic isn't self-evident
  • My changes generate no new warnings or errors

Testing

  • I have tested on Android (physical device or emulator)
  • I have tested on iOS (physical device or simulator)
  • I have tested in light mode and dark mode
  • Existing tests pass locally (npm test)
  • I have added tests that prove my fix is effective or my feature works

React Native Specific

  • No new native module without corresponding platform implementation (Android + iOS)
  • New native modules are added to the Xcode project build target (project.pbxproj)
  • No hardcoded pixel values — uses SPACING / TYPOGRAPHY constants from the theme
  • Styles use useThemedStyles pattern (not inline or static StyleSheet.create)
  • Animations/gestures work smoothly on both platforms
  • Large lists use FlatList / FlashList (not .map() inside ScrollView)
  • No unnecessary re-renders introduced (check with React DevTools Profiler if unsure)

Performance & Models

  • Downloads / long-running tasks report progress to the UI
  • File paths are resolved correctly on both platforms (no hardcoded / vs \\)
  • Large files (models, assets) are not committed to the repository

Security

  • No secrets, API keys, or credentials are included in the code
  • User input is validated/sanitized where applicable

Related Issues

Additional Notes

Summary by CodeRabbit

  • New Features

    • Added an export option for conversations from the chat list.
    • Conversations can be shared as formatted text, including metadata, timestamps, message roles, and attachment counts.
    • Longer conversations are shared as downloadable text files.
  • Bug Fixes

    • Export actions no longer trigger the delete confirmation dialog.
    • Sharing cancellations or failures are handled without disrupting the app.

Adds a "share" swipe action next to delete on the Chats list, letting
users get a conversation out of the app as readable text via the
native share sheet. There was previously no way to extract chat
history from a release build without root/adb access.

- src/utils/exportConversation.ts: pure formatConversationAsText()
  transcript serializer + shareConversationAsText() wrapper. Short
  transcripts go through RN's core Share sheet; long transcripts are
  written to a .txt file and handed to react-native-share, since
  core Share silently drops file attachments on Android.
- ChatsListScreen: export swipe action (share-2 icon) alongside the
  existing delete action.
- 100% unit coverage on the new formatter/share module, plus
  integration tests on ChatsListScreen for the wiring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Conversation export

Layer / File(s) Summary
Transcript serialization and sharing
src/utils/exportConversation.ts, __tests__/unit/utils/exportConversation.test.ts, package.json, jest.setup.ts, jest.config.js
Adds conversation text formatting, inline or file-based sharing, React Native Share mocks, the react-native-share dependency, and complete coverage tests and thresholds.
Chat list export action
src/screens/ChatsListScreen.tsx, __tests__/rntl/screens/ChatsListScreen.test.tsx
Adds an export swipe action that shares the selected conversation while keeping delete confirmation behavior separate and tested.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChatList
  participant ExportUtility
  participant InlineShare
  participant FileSystem
  participant NativeShare
  ChatList->>ExportUtility: shareConversationAsText(conversation)
  ExportUtility->>ExportUtility: formatConversationAsText(conversation)
  alt Short transcript
    ExportUtility->>InlineShare: Share.share(text)
  else Long transcript
    ExportUtility->>FileSystem: writeFile(cache path, text)
    ExportUtility->>NativeShare: open(file share options)
  end
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description includes a summary, but key template sections are left blank, including type of change, screenshots, checklist, related issues, and notes. Fill in the template sections with the change type, UI screenshots for Android/iOS, test/checklist details, any related issues, and any additional notes.
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: exporting chats as a plain-text transcript.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

package.json

Parsing error: Unexpected token :


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Edge Case: File-share threshold counts lines, not size — Android truncation

shareConversationAsText decides between the file path and the inline Share.share path using transcript.split(' ').length > FILE_SHARE_LINE_THRESHOLD (30 lines). The stated purpose of the file path is that RN's core Share silently drops/truncates large payloads on Android. But line count is a poor proxy for payload size: a conversation with a single very long assistant response (common with LLM output) serializes to only ~6 lines — one timestamp line plus one content line per message — so it stays under the threshold and routes through the inline Share.share. On Android, that large message is delivered as an intent extra, which is size-limited (~hundreds of KB via the Binder transaction buffer) and can be truncated or fail. The result is a platform-specific bug: the exact case the file path was designed to handle (large transcripts) can still be routed through the broken inline path. Consider thresholding on transcript.length (character count) instead of, or in addition to, line count.

Route by serialized character length so large single-message chats also use the file path.:

const FILE_SHARE_CHAR_THRESHOLD = 8000; // ~intent-extra safe limit
...
if (transcript.length > FILE_SHARE_CHAR_THRESHOLD) {
  const filePath = `${RNFS.CachesDirectoryPath}/${sanitizeFileName(conversation.title)}.txt`;
  await RNFS.writeFile(filePath, transcript, 'utf8');
  await RNShare.open({ /* ...unchanged... */ });
  return;
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

Comment on lines +81 to +83
} catch {
// User cancelled or the share sheet failed to open — nothing to recover.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Quality: Empty catch swallows genuine share/write failures silently

The catch {} in shareConversationAsText discards every error, with a comment assuming it is only user-cancellation. But RNFS.writeFile (disk full, permission denied) and RNShare.open/Share.share (no share targets, native failure) throw here too. In those cases the user taps the share action and nothing happens — no toast, no alert, no log — which is indistinguishable from a bug and gives no feedback. react-native-share also rejects on user cancel, so cancellation and real failures can't be told apart from an empty catch. Consider distinguishing cancellation from real errors and surfacing genuine failures via the app's alert/toast mechanism (and at minimum logging them), consistent with the project guideline that side effects report their outcome to the UI.

Ignore cancellation but log/surface real failures.:

} catch (err: any) {
  // react-native-share rejects with 'User did not share' on cancel.
  if (err?.message?.includes('User did not share')) return;
  console.warn('[exportConversation] share failed', err);
  // surface a user-facing toast/alert here
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown
Code Review ⚠️ Changes requested 0 resolved / 2 findings

Implements plain-text conversation exports with native share support, but fails on Android truncation due to an incorrect line-count threshold and swallows all error reporting in the sharing utility.

⚠️ Edge Case: File-share threshold counts lines, not size — Android truncation

📄 src/utils/exportConversation.ts:7 📄 src/utils/exportConversation.ts:62-76

shareConversationAsText decides between the file path and the inline Share.share path using transcript.split(' ').length > FILE_SHARE_LINE_THRESHOLD (30 lines). The stated purpose of the file path is that RN's core Share silently drops/truncates large payloads on Android. But line count is a poor proxy for payload size: a conversation with a single very long assistant response (common with LLM output) serializes to only ~6 lines — one timestamp line plus one content line per message — so it stays under the threshold and routes through the inline Share.share. On Android, that large message is delivered as an intent extra, which is size-limited (~hundreds of KB via the Binder transaction buffer) and can be truncated or fail. The result is a platform-specific bug: the exact case the file path was designed to handle (large transcripts) can still be routed through the broken inline path. Consider thresholding on transcript.length (character count) instead of, or in addition to, line count.

Route by serialized character length so large single-message chats also use the file path.
const FILE_SHARE_CHAR_THRESHOLD = 8000; // ~intent-extra safe limit
...
if (transcript.length > FILE_SHARE_CHAR_THRESHOLD) {
  const filePath = `${RNFS.CachesDirectoryPath}/${sanitizeFileName(conversation.title)}.txt`;
  await RNFS.writeFile(filePath, transcript, 'utf8');
  await RNShare.open({ /* ...unchanged... */ });
  return;
}
💡 Quality: Empty catch swallows genuine share/write failures silently

📄 src/utils/exportConversation.ts:81-83

The catch {} in shareConversationAsText discards every error, with a comment assuming it is only user-cancellation. But RNFS.writeFile (disk full, permission denied) and RNShare.open/Share.share (no share targets, native failure) throw here too. In those cases the user taps the share action and nothing happens — no toast, no alert, no log — which is indistinguishable from a bug and gives no feedback. react-native-share also rejects on user cancel, so cancellation and real failures can't be told apart from an empty catch. Consider distinguishing cancellation from real errors and surfacing genuine failures via the app's alert/toast mechanism (and at minimum logging them), consistent with the project guideline that side effects report their outcome to the UI.

Ignore cancellation but log/surface real failures.
} catch (err: any) {
  // react-native-share rejects with 'User did not share' on cancel.
  if (err?.message?.includes('User did not share')) return;
  console.warn('[exportConversation] share failed', err);
  // surface a user-facing toast/alert here
}
🤖 Prompt for agents
Code Review: Implements plain-text conversation exports with native share support, but fails on Android truncation due to an incorrect line-count threshold and swallows all error reporting in the sharing utility.

1. ⚠️ Edge Case: File-share threshold counts lines, not size — Android truncation
   Files: src/utils/exportConversation.ts:7, src/utils/exportConversation.ts:62-76

   `shareConversationAsText` decides between the file path and the inline `Share.share` path using `transcript.split('
   ').length > FILE_SHARE_LINE_THRESHOLD` (30 lines). The stated purpose of the file path is that RN's core `Share` silently drops/truncates large payloads on Android. But line count is a poor proxy for payload size: a conversation with a single very long assistant response (common with LLM output) serializes to only ~6 lines — one timestamp line plus one content line per message — so it stays under the threshold and routes through the inline `Share.share`. On Android, that large message is delivered as an intent extra, which is size-limited (~hundreds of KB via the Binder transaction buffer) and can be truncated or fail. The result is a platform-specific bug: the exact case the file path was designed to handle (large transcripts) can still be routed through the broken inline path. Consider thresholding on `transcript.length` (character count) instead of, or in addition to, line count.

   Fix (Route by serialized character length so large single-message chats also use the file path.):
   const FILE_SHARE_CHAR_THRESHOLD = 8000; // ~intent-extra safe limit
   ...
   if (transcript.length > FILE_SHARE_CHAR_THRESHOLD) {
     const filePath = `${RNFS.CachesDirectoryPath}/${sanitizeFileName(conversation.title)}.txt`;
     await RNFS.writeFile(filePath, transcript, 'utf8');
     await RNShare.open({ /* ...unchanged... */ });
     return;
   }

2. 💡 Quality: Empty catch swallows genuine share/write failures silently
   Files: src/utils/exportConversation.ts:81-83

   The `catch {}` in `shareConversationAsText` discards every error, with a comment assuming it is only user-cancellation. But `RNFS.writeFile` (disk full, permission denied) and `RNShare.open`/`Share.share` (no share targets, native failure) throw here too. In those cases the user taps the share action and nothing happens — no toast, no alert, no log — which is indistinguishable from a bug and gives no feedback. `react-native-share` also rejects on user cancel, so cancellation and real failures can't be told apart from an empty catch. Consider distinguishing cancellation from real errors and surfacing genuine failures via the app's alert/toast mechanism (and at minimum logging them), consistent with the project guideline that side effects report their outcome to the UI.

   Fix (Ignore cancellation but log/surface real failures.):
   } catch (err: any) {
     // react-native-share rejects with 'User did not share' on cancel.
     if (err?.message?.includes('User did not share')) return;
     console.warn('[exportConversation] share failed', err);
     // surface a user-facing toast/alert here
   }

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/utils/exportConversation.ts (1)

62-84: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Temp file is never cleaned up after sharing.

RNFS.writeFile creates a .txt file in the cache directory, but it's never deleted after RNShare.open resolves. Repeated sharing of long conversations accumulates orphaned files until the OS reclaims cache space.

Add cleanup after the share sheet dismisses. Note: on Android, the target app may still be reading the content URI when RNShare.open resolves, so test cleanup timing on device.

♻️ Proposed cleanup after share
       await RNShare.open({
         url: `file://${filePath}`,
         type: 'text/plain',
         filename: sanitizeFileName(conversation.title),
         title: conversation.title,
         failOnCancel: false,
       });
+      // Clean up the temp file after the share sheet is dismissed.
+      // On Android the target app may still read the content URI briefly,
+      // so a short delay avoids deleting the file mid-read.
+      setTimeout(() => RNFS.unlink(filePath).catch(() => {}), 3000);
       return;
🤖 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/utils/exportConversation.ts` around lines 62 - 84, Update
shareConversationAsText around RNFS.writeFile and RNShare.open to delete the
generated filePath after the share sheet completes, including when sharing fails
or is cancelled. Ensure cleanup occurs after RNShare.open resolves while
preserving the existing short-transcript sharing path and error handling.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/screens/ChatsListScreen.tsx`:
- Around line 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.

---

Nitpick comments:
In `@src/utils/exportConversation.ts`:
- Around line 62-84: Update shareConversationAsText around RNFS.writeFile and
RNShare.open to delete the generated filePath after the share sheet completes,
including when sharing fails or is cancelled. Ensure cleanup occurs after
RNShare.open resolves while preserving the existing short-transcript sharing
path and error handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 66fc047f-1905-41cf-ac71-7155f32c9097

📥 Commits

Reviewing files that changed from the base of the PR and between f30dfe2 and 0e9937e.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (7)
  • __tests__/rntl/screens/ChatsListScreen.test.tsx
  • __tests__/unit/utils/exportConversation.test.ts
  • jest.config.js
  • jest.setup.ts
  • package.json
  • src/screens/ChatsListScreen.tsx
  • src/utils/exportConversation.ts

Comment on lines 165 to 182
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>
);

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant