diff --git a/flows/README.md b/flows/README.md new file mode 100644 index 0000000000..12ce64ac51 --- /dev/null +++ b/flows/README.md @@ -0,0 +1,131 @@ +# Maestro E2E Automation + +This directory contains end-to-end UI automation flows for the Keeper app using Maestro. + +## Instructions + +Use these flows to validate onboarding, wallet, RGB assets, settings, backup, and recovery journeys on development builds. + +Before running tests: +- Make sure the app is built and installed on your target device/emulator. +- Use the dev app id expected by flows: `io.hexawallet.keeper`. +- Run from repository root unless noted otherwise. + +## Installation + +### Prerequisites + +- Node.js `>=20` +- Android emulator or iOS simulator (or physical device) +- Tribe app installed with dev flavor + +### Install Maestro CLI + +Use one of the following: + +```bash +brew install maestro +``` + +or + +```bash +curl -Ls "https://get.maestro.mobile.dev" | bash +``` + +Verify installation: + +```bash +maestro --version +``` + +## How To Use + +### 1. Start your device and app + +From repo root, run one of: + +```bash +npm run androidDevDebug +``` + +or + +```bash +npm run ios +``` + +### 2. Run a full regression flow + +```bash +npm run maestro:test:regression-dev +``` + +### 3. Run a single flow directly + +```bash +maestro test flows/addwallet.yaml +``` + +### 4. Run with verbose logs (useful for debugging) + +```bash +maestro test --verbose flows/refreshwallet.yaml +``` + +## Folder Structure + +```text +flows/ +├── addNewKey.yaml +├── addwallet.yaml +├── appsettings.yaml +├── buyBTC.yaml +├── copywalletaddress.yaml +├── editwallet.yaml +├── editwalletdetails.yaml +├── exportseed.yaml +├── healthCheckKey.yaml +├── hidendeletekey.yaml +├── keySetting.yaml +├── login.yaml +├── newapp.yaml +├── receive.yaml +├── receivesats.yaml +├── refreshwallet.yaml +├── sanity.yaml +├── send.yaml +├── setpin.yaml +├── subscription.yaml +├── support_chat.yaml +├── versionhistory.yaml +├── viewwallet.yaml +└── walletSetting.yaml +``` + +## Commands + +### Existing npm script + +```bash +npm run maestro:test:regression +``` + +Runs: + +```bash +maestro test flows/refreshwallet.yaml +``` + +### Useful direct commands + +Run add wallet flow: + +```bash +maestro test flows/addwallet.yaml +``` + + +## Notes + +- If tests fail due to timing, retry with `--verbose` and inspect the exact step that failed. diff --git a/flows/newapp.yaml b/flows/newapp.yaml index e39aec15ad..1fbfed57db 100644 --- a/flows/newapp.yaml +++ b/flows/newapp.yaml @@ -2,15 +2,13 @@ appId: io.hexawallet.keeper --- - runFlow: setpin.yaml - extendedWaitUntil: - visible: 'Welcome' + visible: 'Welcome to Bitcoin Keeper' timeout: 5000 -- assertVisible: - id: 'view_startNewTile' -- tapOn: - id: 'view_startNewTile' +- assertVisible: 'Start New' +- tapOn: 'Start New' - extendedWaitUntil: visible: 'Next' - timeout: 30000 + timeout: 200000 - tapOn: 'Next' - waitForAnimationToEnd: timeout: 2000 @@ -18,3 +16,99 @@ appId: io.hexawallet.keeper notVisible: id: 'toast_message' timeout: 20000 +- extendedWaitUntil: + visible: 'Protect Your Recovery Key' + timeout: 10000 +- assertVisible: "Skip for Now" +- assertVisible: "Back Up Now" +- tapOn: "Skip for Now" +- extendedWaitUntil: + visible: 'Continue Without Backup?' + timeout: 5000 +- assertVisible: "Skip Backup" +- assertVisible: "Backup Now" +- tapOn: "Skip Backup" + + +- tapOn: "Keys" +- tapOn: "Ask" +- tapOn: "More" +- tapOn: "Wallets" +- tapOn: "Mobile Wallet" +- assertVisible: "Pull Down to Refresh" +- tapOn: "Okay" +- tapOn: "Receive\nBitcoin" +- tapOn: + id: "btn_back" +- tapOn: + point: 6%,8% +- tapOn: "More" +- tapOn: "Recovery Key" +- tapOn: + id: "key_1" +- tapOn: + id: "key_1" +- tapOn: + id: "key_1" +- tapOn: + id: "key_1" +- tapOn: "Proceed" +- tapOn: "Backup Now" +- evalScript: ${output.seedWordIndex = 0} + +- repeat: + while: + true: ${output.seedWordIndex <= 12} + commands: + - tapOn: + id: ${"btn_seed_word_" + output.seedWordIndex} + - copyTextFrom: + id: ${"text_seed_word_" + output.seedWordIndex} + - evalScript: ${output["W" + output.seedWordIndex] = maestro.copiedText} + - evalScript: ${output.seedWordIndex = output.seedWordIndex + 1} + +- tapOn: "Next" +- tapOn: "Skip" +- tapOn: + id: "btn_back" +- scroll +- tapOn: + id: "btn_setting_Dark Mode" +- tapOn: + id: "btn_setting_Dark Mode" +- tapOn: + id: "btn_setting_General Preferences" +- tapOn: + id: "btn_back" +- tapOn: + id: "btn_setting_App Settings" +- tapOn: + id: "btn_setting_Server Settings" +- tapOn: + id: "btn_back" +- tapOn: + id: "btn_setting_App Access" +- tapOn: + id: "btn_back" +- tapOn: + id: "btn_setting_Tor Settings" +- tapOn: + id: "btn_back" +- tapOn: + id: "btn_setting_Version History" +- tapOn: + id: "btn_back" +- tapOn: + id: "btn_setting_Multi User Mode" +- tapOn: + id: "btn_back" +- tapOn: + id: "btn_setting_Network Type" +- assertVisible: "Network Type" +- tapOn: + id: "btn_close_modal" +- tapOn: + id: "btn_back" +- tapOn: "Wallets" + + diff --git a/flows/sanity.yaml b/flows/sanity.yaml index eb96b10930..86cc2f29e4 100644 --- a/flows/sanity.yaml +++ b/flows/sanity.yaml @@ -1,6 +1,7 @@ appId: io.hexawallet.keeper --- - runFlow: newapp.yaml +- runFlow: support_chat.yaml - runFlow: addwallet.yaml - runFlow: addNewKey.yaml diff --git a/flows/setpin.yaml b/flows/setpin.yaml index 5b79ff023e..0d22d236ed 100644 --- a/flows/setpin.yaml +++ b/flows/setpin.yaml @@ -35,6 +35,7 @@ appId: io.hexawallet.keeper timeout: 5000 - assertVisible: 'Continue' - tapOn: 'Continue' +- tapOn: 'Cancel' - swipe: direction: LEFT - swipe: @@ -43,5 +44,5 @@ appId: io.hexawallet.keeper direction: LEFT - waitForAnimationToEnd: timeout: 5000 -- assertVisible: 'Start App' -- tapOn: 'Start App' +- assertVisible: 'Get Started' +- tapOn: 'Get Started' diff --git a/flows/support_chat.yaml b/flows/support_chat.yaml new file mode 100644 index 0000000000..9f27d69eb7 --- /dev/null +++ b/flows/support_chat.yaml @@ -0,0 +1,195 @@ +appId: io.hexawallet.keeper +--- +- assertVisible: "Ask" +- tapOn: "Ask" +- assertVisible: + id: "fab_start_chat" +- assertVisible: "No conversations yet" +- assertVisible: "Get help, report a problem, or share an idea." +- assertVisible: "Never share seed words or private keys." +- tapOn: + id: "fab_start_chat" +- assertVisible: "Before you use Ask Keeper" +- assertVisible: + id: "btn_primaryText" +- tapOn: + id: "btn_primaryText" +- assertVisible: + id: "input_message" +- assertVisible: + id: "btn_send" +- assertVisible: + id: "message_0" +- assertVisible: + id: "btn_back" +- assertVisible: "Ask Keeper" +- assertVisible: + id: "btn_learn_more" +- tapOn: + id: "btn_learn_more" +- assertVisible: "Before you use Ask Keeper" +- tapOn: "Got it" +- tapOn: + id: "input_message" +- inputText: "Hello" +- tapOn: + id: "btn_send" +- extendedWaitUntil: + visible: + id: "message_1" + timeout: 5000 +- tapOn: + id: "btn_back" +- assertVisible: + id: "chat_thread_0" +- assertVisible: + id: "chat_thread_title_0" +- assertVisible: + id: "chat_thread_preview_0" +- assertVisible: + id: "chat_thread_title_0" + text: "Hello" +- tapOn: + id: "chat_thread_0" + + +- tapOn: + id: "input_message" +- inputText: "What is keeper?" +- tapOn: + id: "btn_send" +- assertVisible: + id: "message_3" + +- tapOn: + id: "input_message" +- inputText: "Here is my seed: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +- tapOn: + id: "btn_send" +- assertVisible: + id: toast_container +- assertVisible: + id: toast_message +- assertVisible: + text: "This looks like a seed phrase. Please remove it — seed words must never be shared." + +- waitForAnimationToEnd: + timeout: 1000 +- tapOn: + id: "input_message" +- eraseText: 200 + +- tapOn: + id: "input_message" +- inputText: "Here is my xPUB: xpub661MyMwAqRbcF8xHh9Yx8RJWb1x1o1t4bmPqhGV8eK3opgVetwYqKqRR3YKHyCuXapnwMaijvSEezwEWj1vDLteA94ppWKqhzyzk5mpQxbP" +- tapOn: + id: "btn_send" +- assertVisible: + id: toast_container +- assertVisible: + id: toast_message +- assertVisible: + text: "This looks like an extended public key (xpub). Please remove it — sharing your xpub exposes your full transaction history." +- tapOn: + id: "input_message" +- eraseText: 250 + +- tapOn: + id: "input_message" +- inputText: "I want to suggest a feature to add new signer" +- tapOn: + id: "btn_send" +- tapOn: + id: "input_message" +- inputText: "Yes" +- tapOn: + id: "btn_send" +- assertVisible: + id: "help_ai_draft_card" +- assertVisible: "Review & Submit" +- tapOn: "Review & Submit" +- scroll +- assertVisible: "Confirm Public Submission" +- assertVisible: "Cancel" +- tapOn: "Confirm Public Submission" + + +- tapOn: + id: "input_message" +- inputText: "My balances are not showing, I want to talk to support. I am panicking" +- tapOn: + id: "btn_send" +- assertVisible: "Open Advisor" + +- tapOn: + id: "btn_back" + + +- tapOn: + id: "fab_start_chat" + +- tapOn: + id: "input_message" +- evalScript: | + ${ + output.supportQuestions = [ + "Answer only \"Yes\" or \"No\": Is keeper free to use?", + "Answer only \"Yes\" or \"No\": Can Bitcoin Keeper create multisig wallets compatible with other standard Bitcoin wallets?", + "Answer only \"Yes\" or \"No\": Does Bitcoin Keeper avoid proprietary wallet formats?", + "Answer only \"Yes\" or \"No\": Is keeper open source?", + "Answer only \"Yes\" or \"No\": Can users recover their funds without depending on the Keeper app?", + "Answer only \"Yes\" or \"No\": Does Keeper ever custody user private keys?", + "Answer only \"Yes\" or \"No\": Can multiple hardware wallet brands be used together in one multisig setup?", + "Answer only \"Yes\" or \"No\": Can a 2-of-3 multisig wallet still work if one device is lost?", + "Answer only \"Yes\" or \"No\": Are xpubs or wallet descriptors important for multisig recovery?", + "Answer only \"Yes\" or \"No\": Can Keeper import multisig wallets created in another wallet application?", + "Answer only \"Yes\" or \"No\": Does Keeper support coinjoin?", + "Answer only \"Yes\" or \"No\": Does Keeper support Whirlpool?", + "Answer only \"Yes\" or \"No\": Can Keeper export wallet configuration details for backup purposes?", + "Answer only \"Yes\" or \"No\": Does Keeper support air-gapped signing workflows?", + "Answer only \"Yes\" or \"No\": Can a compromised signer key be replaced in a multisig setup?", + "Answer only \"Yes\" or \"No\": Does Keeper support inheritance-oriented wallet structures?", + "Answer only \"Yes\" or \"No\": Can Keeper support timelock-based inheritance setups?", + "Answer only \"Yes\" or \"No\": Can Keeper be used for collaborative custody with multiple people?", + "Answer only \"Yes\" or \"No\": Does Keeper support watch-only wallets?", + "Answer only \"Yes\" or \"No\": Can Keeper be used without creating an online account?", + "Answer only \"Yes\" or \"No\": Are all major wallet features currently free to use?", + "Answer only \"Yes\" or \"No\": Does Keeper support UTXO management or coin control features?", + "Answer only \"Yes\" or \"No\": Can Keeper connect to a personal Electrum server?", + "Answer only \"Yes\" or \"No\": Does Keeper include privacy-enhancing networking options like Tor?", + "Answer only \"Yes\" or \"No\": Can software keys and hardware keys coexist in one multisig vault?", + "Answer only \"Yes\" or \"No\": Can receiving addresses be independently verified on hardware devices?", + "Answer only \"Yes\" or \"No\": Does Keeper provide recovery guidance for lost or failed signing devices?", + "Answer only \"Yes\" or \"No\": Can Keeper-created multisig wallets be restored in another standards-compatible wallet?", + "Answer only \"Yes\" or \"No\": Should I test wallet recovery procedures before storing significant Bitcoin amounts?" + ] + } +- evalScript: ${output.supportQuestionIndex = 0} +- repeat: + while: + true: ${output.supportQuestionIndex < output.supportQuestions.length} + commands: + - evalScript: ${output.currentMessageIndex = output.supportQuestionIndex * 2} + - tapOn: + id: "input_message" + - inputText: ${output.supportQuestions[output.supportQuestionIndex]} + - tapOn: + id: "btn_send" + - assertVisible: + id: ${"message_" + output.currentMessageIndex} + - assertVisible: + id: ${"user_message_" + output.currentMessageIndex} + # text: ${output.supportQuestions[output.supportQuestionIndex]} + - extendedWaitUntil: + visible: + id: ${"message_" + (output.currentMessageIndex + 1)} + timeout: 10000 + - extendedWaitUntil: + visible: + id: ${"ai_message_" + (output.currentMessageIndex + 1)} + timeout: 10000 + - evalScript: ${output.supportQuestionIndex = output.supportQuestionIndex + 1} + +- tapOn: + id: "btn_back" +- tapOn: "Wallets" diff --git a/openspec/changes/archive/2026-05-21-visible-network-indicator/.openspec.yaml b/openspec/changes/archive/2026-05-21-visible-network-indicator/.openspec.yaml new file mode 100644 index 0000000000..af43829ce6 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-visible-network-indicator/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-21 diff --git a/openspec/changes/archive/2026-05-21-visible-network-indicator/design.md b/openspec/changes/archive/2026-05-21-visible-network-indicator/design.md new file mode 100644 index 0000000000..007fcaf934 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-visible-network-indicator/design.md @@ -0,0 +1,77 @@ +## Context + +Bitcoin Keeper has a global Network Type setting (Mainnet / Testnet) stored in the Redux `settings` slice (`state.settings.bitcoinNetworkType`). When Testnet is active the app routes all wallet, key, node, receive, and send operations to the Bitcoin testnet, yet the UI currently gives no persistent visual confirmation of this on the core workflow screens. A user who has switched to Testnet has to navigate back to App Settings to verify their network mode. + +The `TestnetIndicator` component (`src/components/TestnetIndicator.tsx`) already exists and is used on the Login screen via `{isTestnet() && }`. The `isTestnet()` utility reads the Redux store synchronously; it is not reactive and will not re-render when the store changes while a screen is mounted. + +`HomeScreenHeader` (`src/components/HomeScreenHeader.tsx`) renders the title for the Wallets, Keys, Buy, Ask AI, and More tabs via a `title` prop passed from `HomeScreen`. + +`WalletHeader` (`src/components/WalletHeader.tsx`) renders the back-navigation header on nested screens (Receive Bitcoin, Send Confirmation, etc.) via a `title` prop. It already has a `rightComponent` slot used on Send Confirmation for `CurrencyTypeSwitch`. + +## Goals / Non-Goals + +**Goals:** + +- Display `(Testnet)` inline with the header title on four surfaces: Wallets tab, Keys tab, Receive Bitcoin screen, Send Confirmation screen. +- Derive indicator visibility solely from `state.settings.bitcoinNetworkType` using `useAppSelector`, so it responds reactively when the user changes the network without restarting the app. +- Zero layout shift, no new vertical space consumed, no interactive element added. + +**Non-Goals:** + +- No Mainnet indicator. +- No new components, routes, modals, or banners. +- No change to the Network Type setting or switching flow. +- No indicator on screens outside the four listed above. +- No saga, reducer, or store shape changes. +- No Realm schema changes or MMKV additions. + +## Decisions + +### Decision 1: Append `(Testnet)` to the title string (Path A) rather than composing a badge component inline + +**Chosen**: Conditionally derive the title string at the point of use. When `bitcoinNetworkType === NetworkType.TESTNET`, the string passed to the header's `title` prop is `" (Testnet)"`. In Mainnet the base title is passed unchanged. + +**Alternatives considered**: +- *Path B — Badge component*: Render the existing `TestnetIndicator` pill inside `HomeScreenHeader` / `WalletHeader` alongside the title. This reuses the existing pill and matches the Login screen pattern, but would require modifying the two header components (adding internal Redux reads) and introduces a horizontal layout element that may conflict with the notification bell or `rightComponent` slot on small screens. + +**Rationale for Path A**: Minimal surface area — only three call sites change, both header components stay unchanged. The `(Testnet)` text is the exact copy required by the spec. No new component or layout concern. `WalletHeader`'s `numberOfLines={1}` on title is a minor truncation risk on very narrow widths, but the longest affected string is `"Send Confirmation (Testnet)"` (~30 chars) which fits within the available flex space on all supported screen widths. + +### Decision 2: Use `useAppSelector` (not `isTestnet()`) for reactivity + +**Chosen**: Each affected component reads `const { bitcoinNetworkType } = useAppSelector((state) => state.settings)` directly. This ensures React re-renders when the global network state changes. + +**Rationale**: `isTestnet()` is a synchronous store read outside the React render cycle — it captures a snapshot at mount time and misses subsequent changes. `useAppSelector` subscribes to the Redux store and triggers re-renders on change, satisfying the requirement that indicators disappear immediately when the user switches back to Mainnet. + +### Decision 3: Scope changes to `HomeScreen.tsx`, `ReceiveScreen.tsx`, and `SendConfirmation.tsx` + +The Wallets and Keys titles are both derived from the `selectedOption` state in `HomeScreen` and passed as a single `title` prop to `HomeScreenHeader`. Appending there avoids touching the shared header component. Similarly, Receive and Send Confirmation each pass a static string to `WalletHeader`. + +**Redux slices involved**: `settings` (read-only, `bitcoinNetworkType`). +**Sagas involved**: none. +**Realm / MMKV**: no changes. +**Store migration**: not required (no shape change). + +## Affected Files + +| File | Change type | +|------|-------------| +| `src/screens/Home/HomeScreen.tsx` | Modified — derive testnet-suffixed title for Wallets and Keys tabs | +| `src/screens/Recieve/ReceiveScreen.tsx` | Modified — pass testnet-suffixed title to `WalletHeader` | +| `src/screens/Send/SendConfirmation.tsx` | Modified — pass testnet-suffixed title to `WalletHeader` | + +No new files. No component files modified. + +## Risks / Trade-offs + +- **Title truncation on narrow screens**: `WalletHeader` sets `numberOfLines={1}` on the title `Text`. The longest new string (`"Send Confirmation (Testnet)"`) is ~30 characters. On 320 pt (iPhone SE 1st gen) this sits comfortably within the flex space after the back arrow. Risk: low. +- **`HomeScreenHeader` dynamic title logic**: `HomeScreenHeader.getHeaderTitle()` applies `capitalizeEachWord()` to the title. The string `"Wallets (Testnet)"` will become `"Wallets (Testnet)"` after capitalization (already capitalized correctly). Risk: none. +- **Reactivity during navigation**: `HomeScreen` keeps `selectedOption` in local state and reads `bitcoinNetworkType` from Redux. If the user changes network while on the Wallets tab the component will re-render via `useAppSelector` and show the updated title without navigation. Receive/Send Confirmation screens similarly subscribe. +- **No new tests needed for store logic** (no store changes). UI-level snapshot or render tests covering the title string conditional are the appropriate test target. + +## Migration Plan + +Display-only change. No deployment steps, no feature flag, no rollback concern. If reverted, the three files return to their previous state and no persistent data is affected. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-05-21-visible-network-indicator/proposal.md b/openspec/changes/archive/2026-05-21-visible-network-indicator/proposal.md new file mode 100644 index 0000000000..af7d94836b --- /dev/null +++ b/openspec/changes/archive/2026-05-21-visible-network-indicator/proposal.md @@ -0,0 +1,40 @@ +## Why + +Users who switch to Testnet have no visible confirmation of the active network on core wallet surfaces, creating a risk of confusion — particularly at the point of receiving or sending, where a Testnet address or transaction could be mistaken for a Mainnet one. A lightweight, non-intrusive indicator on the screens where network mistakes matter most removes that ambiguity. + +## What Changes + +- **Wallets tab header** now shows `Wallets (Testnet)` when Testnet is active; reverts to `Wallets` on Mainnet. +- **Keys tab header** now shows `Keys (Testnet)` when Testnet is active; reverts to `Keys` on Mainnet. +- **Receive Bitcoin screen header** now shows `Receive Bitcoin (Testnet)` when Testnet is active. +- **Send Confirmation screen header** now shows `Send Confirmation (Testnet)` (existing title + ` (Testnet)`) when Testnet is active. +- The indicator is display-only: non-tappable, no new routes, no layout shift. +- No indicator is shown in Mainnet mode anywhere. + +## Capabilities + +### New Capabilities + +- `testnet-indicator`: Inline `(Testnet)` suffix appended to the header title on the Wallets tab, Keys tab, Receive Bitcoin screen, and Send Confirmation screen when the global Network Type is Testnet. + +### Modified Capabilities + +- `settings`: The global Network Type setting already exists; this change adds a display side-effect driven by it. No requirement changes to how the setting is stored or toggled. + +## Impact + +- **Affected files**: `src/components/HomeScreenHeader.tsx`, `src/screens/Recieve/ReceiveScreen.tsx`, `src/screens/Send/SendConfirmation.tsx` +- **State**: Reads `state.settings.bitcoinNetworkType` via `useAppSelector` — no new state, no store shape changes, no migration needed. +- **No API or network calls added.** +- **No hardware signer compatibility implications.** +- **No subscription tier gating** — the indicator is visible to all users when Testnet is active. +- **Security/privacy**: Display-only change; no key material is accessed, stored, or transmitted. +- **Environments**: Testnet-only UI change (indicator shown only in Testnet mode). + +## Non-Goals + +- No Mainnet indicator. +- No tappable indicator or new network-switcher shortcut. +- No full-width banner or new visual system. +- No changes to the Network Type setting flow itself. +- No indicator on screens beyond the four listed above. diff --git a/openspec/changes/archive/2026-05-21-visible-network-indicator/specs/testnet-indicator/spec.md b/openspec/changes/archive/2026-05-21-visible-network-indicator/specs/testnet-indicator/spec.md new file mode 100644 index 0000000000..809f63747e --- /dev/null +++ b/openspec/changes/archive/2026-05-21-visible-network-indicator/specs/testnet-indicator/spec.md @@ -0,0 +1,75 @@ +## ADDED Requirements + +### Requirement: Wallets tab header shows Testnet indicator when Testnet is active +When the global Network Type is Testnet, the Wallets tab header title SHALL display `Wallets (Testnet)`. When the global Network Type is Mainnet, the title SHALL display `Wallets` with no suffix. + +#### Scenario: Wallets tab in Testnet mode +- **WHEN** the user opens the Wallets tab and `bitcoinNetworkType` is `TESTNET` +- **THEN** the `HomeScreenHeader` title shows `Wallets (Testnet)` + +#### Scenario: Wallets tab in Mainnet mode +- **WHEN** the user opens the Wallets tab and `bitcoinNetworkType` is `MAINNET` +- **THEN** the `HomeScreenHeader` title shows `Wallets` with no `(Testnet)` suffix + +#### Scenario: Wallets tab title updates when network changes without restart +- **WHEN** the user is on the Wallets tab and changes Network Type from Testnet to Mainnet +- **THEN** the header title updates immediately to `Wallets` without requiring a restart or navigation change + +### Requirement: Keys tab header shows Testnet indicator when Testnet is active +When the global Network Type is Testnet, the Keys tab header title SHALL display `Keys (Testnet)`. When the global Network Type is Mainnet, the title SHALL display `Keys` with no suffix. + +#### Scenario: Keys tab in Testnet mode +- **WHEN** the user opens the Keys tab and `bitcoinNetworkType` is `TESTNET` +- **THEN** the `HomeScreenHeader` title shows `Keys (Testnet)` + +#### Scenario: Keys tab in Mainnet mode +- **WHEN** the user opens the Keys tab and `bitcoinNetworkType` is `MAINNET` +- **THEN** the `HomeScreenHeader` title shows `Keys` with no `(Testnet)` suffix + +#### Scenario: Keys tab title updates when network changes without restart +- **WHEN** the user is on the Keys tab and changes Network Type from Mainnet to Testnet +- **THEN** the header title updates immediately to `Keys (Testnet)` without requiring a restart or navigation change + +### Requirement: Receive Bitcoin screen header shows Testnet indicator when Testnet is active +When the global Network Type is Testnet, the Receive Bitcoin screen header title SHALL display `Receive Bitcoin (Testnet)`. When Mainnet, it SHALL display `Receive Bitcoin` with no suffix. + +#### Scenario: Receive Bitcoin screen in Testnet mode +- **WHEN** the user opens the Receive Bitcoin screen and `bitcoinNetworkType` is `TESTNET` +- **THEN** the `WalletHeader` title shows `Receive Bitcoin (Testnet)` + +#### Scenario: Receive Bitcoin screen in Mainnet mode +- **WHEN** the user opens the Receive Bitcoin screen and `bitcoinNetworkType` is `MAINNET` +- **THEN** the `WalletHeader` title shows `Receive Bitcoin` with no `(Testnet)` suffix + +#### Scenario: QR code and address layout are unaffected +- **WHEN** the Testnet indicator is shown on the Receive Bitcoin screen +- **THEN** the QR code, receive address, and all other UI elements remain in their existing positions + +### Requirement: Send Confirmation screen header shows Testnet indicator when Testnet is active +When the global Network Type is Testnet, the Send Confirmation screen header title SHALL display the existing title with `(Testnet)` appended. When Mainnet, the existing title SHALL be shown with no suffix. + +#### Scenario: Send Confirmation screen in Testnet mode +- **WHEN** the user reaches the Send Confirmation screen and `bitcoinNetworkType` is `TESTNET` +- **THEN** the `WalletHeader` title shows the existing confirmation title with ` (Testnet)` appended + +#### Scenario: Send Confirmation screen in Mainnet mode +- **WHEN** the user reaches the Send Confirmation screen and `bitcoinNetworkType` is `MAINNET` +- **THEN** the `WalletHeader` title shows the existing confirmation title with no `(Testnet)` suffix + +#### Scenario: Transaction details are unaffected +- **WHEN** the Testnet indicator is shown on the Send Confirmation screen +- **THEN** all transaction fee details, amounts, recipient addresses, and action buttons remain unaffected + +### Requirement: Testnet indicator is non-interactive +The Testnet indicator text SHALL NOT respond to tap gestures. Tapping the header area where the indicator appears SHALL NOT navigate to Network Type settings or trigger any action. + +#### Scenario: Tap on indicator has no effect +- **WHEN** the user taps on the `(Testnet)` portion of a header title +- **THEN** no navigation occurs and no modal, bottom sheet, or toast is triggered + +### Requirement: No Mainnet indicator is shown +In Mainnet mode, no network indicator SHALL appear on any screen. + +#### Scenario: No indicator in Mainnet mode +- **WHEN** `bitcoinNetworkType` is `MAINNET` +- **THEN** none of the four affected screen headers show any network suffix or indicator diff --git a/openspec/changes/archive/2026-05-21-visible-network-indicator/tasks.md b/openspec/changes/archive/2026-05-21-visible-network-indicator/tasks.md new file mode 100644 index 0000000000..ee18e53d91 --- /dev/null +++ b/openspec/changes/archive/2026-05-21-visible-network-indicator/tasks.md @@ -0,0 +1,29 @@ +## 1. HomeScreen — Wallets & Keys Tab Titles + +- [x] 1.1 In `src/screens/Home/HomeScreen.tsx`, add `useAppSelector` read of `bitcoinNetworkType` from `state.settings` +- [x] 1.2 Derive a `testnetSuffix` constant: `' (Testnet)'` when `bitcoinNetworkType === NetworkType.TESTNET`, otherwise `''` +- [x] 1.3 In the `getContent()` switch, append `testnetSuffix` to the `walletText.homeWallets` string when building the Wallets case title — pass as the `title` prop to `HomeScreenHeader` +- [x] 1.4 In the `getContent()` switch, append `testnetSuffix` to the `walletText.keys` string when building the Keys case title — pass as the `title` prop to `HomeScreenHeader` +- [x] 1.5 Verify that the `HomeScreenHeader`'s `getHeaderTitle` / `capitalizeEachWord` does not mangle `(Testnet)` — confirm by inspection + +## 2. Receive Bitcoin Screen + +- [x] 2.1 In `src/screens/Recieve/ReceiveScreen.tsx`, add `useAppSelector` read of `bitcoinNetworkType` from `state.settings` +- [x] 2.2 Import `NetworkType` from `src/services/wallets/enums` if not already imported +- [x] 2.3 Derive the header title: `bitcoinNetworkType === NetworkType.TESTNET ? \`${common.receive} (Testnet)\` : common.receive` +- [x] 2.4 Pass the derived title string to the `WalletHeader` `title` prop at line ~353 + +## 3. Send Confirmation Screen + +- [x] 3.1 In `src/screens/Send/SendConfirmation.tsx`, add `useAppSelector` read of `bitcoinNetworkType` from `state.settings` +- [x] 3.2 Import `NetworkType` from `src/services/wallets/enums` if not already imported +- [x] 3.3 Derive the header title: `bitcoinNetworkType === NetworkType.TESTNET ? \`${common.sendConfirmation} (Testnet)\` : common.sendConfirmation` +- [x] 3.4 Pass the derived title string to the `WalletHeader` `title` prop at line ~653 + +## 4. Tests + +- [x] 4.1 Add/update a render test for `HomeScreen` (or `HomeScreenHeader`) asserting that `Wallets (Testnet)` appears in the header when `bitcoinNetworkType` is `TESTNET` +- [x] 4.2 Add/update a render test asserting that `Keys (Testnet)` appears when `bitcoinNetworkType` is `TESTNET` +- [x] 4.3 Add/update a render test for `ReceiveScreen` asserting `Receive Bitcoin (Testnet)` in the header when `bitcoinNetworkType` is `TESTNET` +- [x] 4.4 Add/update a render test for `SendConfirmation` asserting the title includes `(Testnet)` when `bitcoinNetworkType` is `TESTNET` +- [x] 4.5 Add render tests asserting no `(Testnet)` text appears in any of the four headers when `bitcoinNetworkType` is `MAINNET` diff --git a/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/.openspec.yaml b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/.openspec.yaml new file mode 100644 index 0000000000..9e883bff04 --- /dev/null +++ b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-25 diff --git a/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/design.md b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/design.md new file mode 100644 index 0000000000..4f6d73b3db --- /dev/null +++ b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/design.md @@ -0,0 +1,118 @@ +## Context + +The Ask Keeper chat feature currently guards against sensitive data leakage with a single regex applied at send time in `HelpAiChat.tsx`: + +```ts +const SENSITIVE_INPUT_PATTERN = /(seed\s*phrase|mnemonic|xpriv|private\s*key|passphrase)/i; +``` + +This misses the most dangerous real-world scenarios: a user pasting 12 or 24 BIP39 seed words (no trigger phrase present), pasting an actual extended key string like `xpub6FNxwLz...` or `xprv9s21ZrQ...` (the pattern only catches the label `xpriv`, not the key itself), and WIF-encoded raw private keys. Additionally, the draft submission flow to GitHub public issues has zero scanning — the highest-risk leakage path in the feature. + +The `bip39` library is already a project dependency (used in `bhr.ts`, `storage.ts`, `wallets/operations/utils.ts`). The full 2048-word English wordlist is available from the attached `bip39.txt` file and can be shipped as a static constant with no new dependencies. + +## Goals / Non-Goals + +**Goals:** +- Block BIP39 mnemonic sequences (8+ consecutive BIP39 words) before they reach the backend +- Block actual extended private and public key strings (long base58 suffix, not just the label word) +- Block WIF-encoded private keys +- Block keyword phrases that signal the user is describing key material +- Scan all draft fields before GitHub issue submission and hard-block if sensitive data found +- Keep all detection synchronous and on-device; nothing leaves the device to perform the check + +**Non-Goals:** +- Real-time / on-change scanning or inline warning UI (send-time only) +- Backend scanning (separate concern, separate task) +- Passphrase keyword blocking (removed — too many false positives for legitimate BIP39 passphrase questions) +- Scanning AI reply content (responses come from backend, controlled separately) +- Detecting wallet descriptors that contain only xpub material (covered by xpub key pattern) + +## Decisions + +### Decision 1: Dedicated utility file, not inline regex + +**Choice**: Create `src/utils/helpAiSensitiveData.ts` exporting `detectSensitiveInput(text)` and `detectSensitiveInDraft(draft)`. + +**Rationale**: The existing `helpAiLinkPolicy.ts` file establishes this exact pattern — security concerns as standalone, testable utilities. Keeping detection logic in the screen component makes it impossible to unit test without mounting the full component, and couples security logic to UI. A utility file can be imported from both `HelpAiChat.tsx` (chat send) and any future surface. + +**Alternative considered**: Expand the inline regex. Rejected — a single regex cannot express the BIP39 word-sequence detection, and a growing monolithic pattern becomes unmaintainable and untestable. + +--- + +### Decision 2: BIP39 word Set from a static constant, not runtime import + +**Choice**: Create `src/constants/bip39WordSet.ts` — a file that exports `const BIP39_WORD_SET: Set` built from the 2048-word English list embedded as a JS `Set` literal. + +**Rationale**: The `bip39` library's `wordlists.english` array is available at runtime but requires importing the full `bip39` module into the utility. A static `Set` constant is smaller, tree-shakeable, loads synchronously with zero async overhead, gives O(1) per-word lookup, and keeps the utility file side-effect-free. The wordlist never changes (it is a Bitcoin standard), so a static file is appropriate. + +**Detection logic**: Tokenize input on whitespace → scan for the longest consecutive run where every token (lowercased) is in `BIP39_WORD_SET` → if run ≥ 8, flag as `mnemonic`. + +**Threshold — why 8**: A 12-word mnemonic is the minimum valid BIP39 length. Using 8 consecutive words provides a safety margin that catches truncated pastes and partial mnemonics, while making accidental false positives on natural language essentially impossible. The probability that 8 consecutive ordinary English words are all from the 2048-word BIP39 list by chance is negligibly small. + +**Alternative considered**: Use `bip39.validateMnemonic()` — only catches exactly 12 or 24 valid checksummed mnemonics. Rejected — misses partial pastes, mnemonics from other languages, and is too strict (a user pasting their words one by one could still leak many words before triggering). + +--- + +### Decision 3: Extended key detection via prefix + minimum base58 length + +**Choice**: Match `(xprv|yprv|zprv|Xprv|Yprv|Zprv|tprv|uprv|vprv)` OR `(xpub|ypub|zpub|Xpub|Ypub|Zpub|tpub|upub|vpub)` followed by `[1-9A-HJ-NP-Za-km-z]{100,}`. + +**Rationale**: The words "xpub" or "xprv" alone are commonly used in support discussions ("How do I find my xpub?"). The actual key format is the 4-char prefix followed by ~107 base58 chars. Requiring 100+ base58 chars after the prefix ensures we only match actual key material, not documentation references. Both extended private and extended public keys are blocked — xpubs expose full transaction history and wallet structure. + +**Alternative considered**: Match on the 4-char prefix alone (current `xpriv` keyword approach). Rejected — too many false positives and the current pattern doesn't even match the correct format (`xpriv` vs `xprv`). + +--- + +### Decision 4: WIF detection via start character + length + +**Choice**: Match `\b[5KLc][1-9A-HJ-NP-Za-km-z]{49,51}\b`. + +- `5` = mainnet uncompressed WIF (51 chars total) +- `K` or `L` = mainnet compressed WIF (52 chars total) +- `c` = testnet WIF (52 chars total) + +**Rationale**: WIF keys have a highly distinctive structure — a specific starting character plus a specific length in base58. The combination of start character and length range makes false positives extremely unlikely in a chat context. + +**Risk**: The letter `c` starting a 50-52 char base58 string could theoretically appear in other contexts. Accepted — the probability in chat input is negligible and it's preferable to over-block than under-block on private key material. + +--- + +### Decision 5: Draft scanning hard-blocks submission + +**Choice**: In `submitDraftIssue()`, scan all draft string fields (concatenated: `title`, `steps` joined, `expected`, `actual`, `problem`, `proposed`) using `detectSensitiveInDraft()` before the first dispatch or API call. If detection returns a result, call `showToast()` with an explanation and return early — do not dispatch `setHelpAiDraftStatus('submitting')`. + +**Rationale**: GitHub issues are permanent, public, and indexed. The draft is AI-generated from the conversation, but the conversation could have been partially blocked, leaving the AI to summarize context the user provided before the blocked message. The scan is cheap (synchronous, no network) and the cost of a false positive (user sees a toast, fixes draft by continuing chat) is far lower than the cost of leaking key material to a public GitHub issue. + +--- + +### Decision 6: No keyword-label detection + +**Choice**: Do not detect keyword phrases like "seed phrase", "private key", "mnemonic", etc. + +**Rationale**: Keyword labels are not themselves sensitive data — they are words used to discuss sensitive data. A user asking "how do I set up a seed phrase?" or "why did my private key export fail?" should not be blocked. The cryptographic format detectors (extended keys, WIF) and the BIP39 word-sequence detector already catch the actual material that must not leave the device. Adding keyword matching on top creates false positives without meaningfully improving security. + +**Alternative considered**: Block keyword phrases as an early warning signal. Rejected — the signal-to-noise ratio is poor. The real threat is the user accidentally pasting actual key material, which the format and mnemonic detectors handle precisely. + +## Risks / Trade-offs + +**[BIP39 false positive on domain-specific terms]** → Some BIP39 words (`metal`, `member`, `network`, `normal`, etc.) are common English words. A user typing a very long technical paragraph could theoretically hit 8 consecutive BIP39 words. The threshold of 8 consecutive with the specific BIP39-only vocabulary makes this highly unlikely. If it does occur, the user sees a toast and can rephrase. + +**[xpub blocking frustrates power users]** → A user asking about importing an xpub for watch-only wallet setup might paste their xpub key. They will be blocked. This is intentional — xpubs should be handled via dedicated import flows, not AI chat. The toast message should clearly explain and redirect them. + +**[No real-time feedback]** → Users only learn their input is blocked at send time. This is acceptable for the initial implementation. Send-time is a hard gate; the UX cost is a toast rather than inline highlighting. + +**[Draft can't be edited inline]** → If a draft is blocked, the user cannot edit the draft fields directly in the UI; they must cancel and continue the conversation to regenerate a clean draft. This is a known limitation of the current draft flow. + +## Migration Plan + +1. Create `src/constants/bip39WordSet.ts` from the 2048-word list +2. Create `src/utils/helpAiSensitiveData.ts` with detection functions +3. Write unit tests in `tests/helpAiSensitiveData.test.ts` +4. Update `HelpAiChat.tsx`: replace inline regex with `detectSensitiveInput`, add `detectSensitiveInDraft` call in `submitDraftIssue` +5. No migration needed — no stored data format changes, no Redux migration required + +**Rollback**: All changes are frontend-only. Removing the new utility and reverting `HelpAiChat.tsx` to the previous regex restores the prior state. No data migration needed. + +## Open Questions + +None — all design questions resolved in the explore session prior to this proposal. diff --git a/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/proposal.md b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/proposal.md new file mode 100644 index 0000000000..ecd65a67aa --- /dev/null +++ b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/proposal.md @@ -0,0 +1,42 @@ +## Why + +The Ask Keeper AI chat feature must never transmit sensitive Bitcoin key material or seed words to the backend. The current frontend guard uses a single narrow regex (`seed phrase`, `mnemonic`, `xpriv`, `private key`, `passphrase`) that misses the most dangerous real-world inputs: pasted BIP39 mnemonics (12/24 words), actual extended key strings (`xpub`/`xprv`/`zprv` etc.), and WIF-encoded private keys. Additionally, the draft submission path to public GitHub issues has no scanning at all, making it the highest-risk leakage surface. + +## What Changes + +- **Replace** the bare `SENSITIVE_INPUT_PATTERN` regex in `HelpAiChat.tsx` with a call to a new dedicated utility `src/utils/helpAiSensitiveData.ts` +- **Add** cryptographic format detection: extended private keys (`xprv`/`yprv`/`zprv` + variants) and extended public keys (`xpub`/`ypub`/`zpub` + variants) only when they match the actual long base58 key format (not the word alone) +- **Add** WIF private key format detection (51–52 char base58 strings starting with `5`, `K`, `L`, or `c`) +- **Add** BIP39 mnemonic sequence detection: flag input containing 8 or more consecutive tokens that are all valid BIP39 words, using a pre-built `Set` from the full 2048-word English wordlist +- **Remove** keyword-label matching entirely — labels like "seed phrase" or "private key" are not actual sensitive data; the cryptographic format and mnemonic detectors cover the real risk +- **Add** draft scan gate in `submitDraftIssue()`: scan all draft fields before any API call; hard-block submission if sensitive data is detected and show a clear explanation +- **Add** unit tests for all detection categories, including true-positive mnemonic/key fixtures and natural-language false-negative cases + +## Capabilities + +### New Capabilities + +- `help-ai-sensitive-input-guard`: Client-side detection and blocking of sensitive Bitcoin key material (BIP39 mnemonics, extended keys, WIF keys, keyword phrases) in both chat message sends and draft issue submissions, implemented as a standalone testable utility + +### Modified Capabilities + +- none + +## Impact + +- **Files modified**: `src/screens/HelpAi/HelpAiChat.tsx`, `src/utils/helpAiLinkPolicy.ts` (no change — pattern follows its conventions) +- **Files created**: `src/utils/helpAiSensitiveData.ts`, `src/constants/bip39WordSet.ts`, `tests/helpAiSensitiveData.test.ts` +- **Dependencies**: `bip39` (already in project — used in `bhr.ts`, `storage.ts`, `wallets/operations/utils.ts`); no new packages needed +- **Backend**: No backend changes. This is a pure frontend defence layer. Backend scanning is a separate concern. +- **Networks**: Applies to mainnet and testnet equally — both use BIP39 mnemonics and the same extended key formats +- **Hardware signers**: No impact +- **Subscription gating**: None — this guard applies to all users +- **Security impact**: Prevents seed phrases, private keys, and extended keys from leaving the device through the chat or GitHub issue submission paths + +## Non-goals + +- Real-time / on-change inline warning UI (send-time blocking only) +- Backend-side scanning or redaction (separate task) +- xpub soft-warning flow (xpub with full key format is hard-blocked, same as xprv) +- Scanning of AI reply content for sensitive data (AI responses are backend-controlled) +- Detection of wallet descriptors that do not contain embedded private key material diff --git a/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/specs/help-ai-sensitive-input-guard/spec.md b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/specs/help-ai-sensitive-input-guard/spec.md new file mode 100644 index 0000000000..32e1c91f18 --- /dev/null +++ b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/specs/help-ai-sensitive-input-guard/spec.md @@ -0,0 +1,111 @@ +## ADDED Requirements + +### Requirement: Block BIP39 mnemonic sequences in chat input +The system SHALL detect and block any chat message that contains 8 or more consecutive whitespace-separated tokens where every token is a valid BIP39 English word, preventing seed phrases from reaching the backend. + +#### Scenario: User pastes a 12-word seed phrase +- **WHEN** the user attempts to send a message containing 12 consecutive BIP39 words (e.g. "abandon ability able about above absent absorb abstract absurd abuse access accident") +- **THEN** the message MUST NOT be transmitted to the backend +- **AND** a toast message SHALL be shown informing the user that seed phrase content was detected + +#### Scenario: User pastes a 24-word seed phrase +- **WHEN** the user attempts to send a message containing 24 consecutive BIP39 words +- **THEN** the message MUST NOT be transmitted to the backend +- **AND** a toast message SHALL be shown + +#### Scenario: User pastes 8 consecutive BIP39 words embedded in a sentence +- **WHEN** the user sends a message where at least 8 consecutive tokens are all valid BIP39 words +- **THEN** the message MUST NOT be transmitted to the backend + +#### Scenario: Legitimate message with incidental BIP39 words below threshold +- **WHEN** the user sends a message containing fewer than 8 consecutive BIP39 words (e.g. "I need help with my wallet") +- **THEN** the message SHALL be transmitted normally to the backend + +--- + +### Requirement: Block actual extended key strings in chat input +The system SHALL detect and block any chat message containing a string that matches an extended private or extended public key format — defined as one of the known 4-character key prefixes (`xprv`, `yprv`, `zprv`, `Xprv`, `Yprv`, `Zprv`, `tprv`, `uprv`, `vprv`, `xpub`, `ypub`, `zpub`, `Xpub`, `Ypub`, `Zpub`, `tpub`, `upub`, `vpub`) immediately followed by 100 or more base58 characters. + +#### Scenario: User pastes an xprv extended private key +- **WHEN** the user attempts to send a message containing a string like `xprv9s21ZrQH143K...` (prefix + 100+ base58 chars) +- **THEN** the message MUST NOT be transmitted to the backend +- **AND** a toast message SHALL be shown + +#### Scenario: User pastes an xpub extended public key +- **WHEN** the user attempts to send a message containing a string like `xpub6FNxwLzfLRiw...` (prefix + 100+ base58 chars) +- **THEN** the message MUST NOT be transmitted to the backend +- **AND** a toast message SHALL be shown + +#### Scenario: User writes the word "xpub" without an actual key +- **WHEN** the user sends a message like "How do I find my xpub?" without a long base58 suffix +- **THEN** the message SHALL be transmitted normally +- **AND** no sensitive-data block SHALL trigger + +#### Scenario: User writes the word "xprv" without an actual key +- **WHEN** the user sends a message like "I read about xprv in the docs" +- **THEN** the message SHALL be transmitted normally + +--- + +### Requirement: Block WIF-encoded private keys in chat input +The system SHALL detect and block any chat message containing a string that matches the WIF private key format: a base58 string of 50–52 characters starting with `5`, `K`, `L`, or `c`. + +#### Scenario: User pastes a WIF mainnet compressed private key +- **WHEN** the user attempts to send a message containing a WIF key starting with `K` or `L` followed by 51 base58 chars +- **THEN** the message MUST NOT be transmitted to the backend +- **AND** a toast message SHALL be shown + +#### Scenario: User pastes a WIF mainnet uncompressed private key +- **WHEN** the user attempts to send a message containing a WIF key starting with `5` followed by 50 base58 chars +- **THEN** the message MUST NOT be transmitted to the backend + +--- + +### Requirement: Do not block keyword-only messages +The system SHALL NOT block chat messages solely because they contain words like "seed phrase", "private key", "mnemonic", or similar labels. Only actual cryptographic material (extended keys, WIF keys) and BIP39 mnemonic sequences SHALL trigger blocking. + +#### Scenario: User asks about seed phrase setup +- **WHEN** the user sends a message like "How do I set up a seed phrase backup?" +- **THEN** the message SHALL be transmitted normally to the backend +- **AND** no sensitive-data block SHALL trigger + +#### Scenario: User mentions private key in a question +- **WHEN** the user sends a message like "Why did my private key export fail on Coldcard?" +- **THEN** the message SHALL be transmitted normally + +--- + +### Requirement: Scan draft fields before GitHub issue submission +The system SHALL run sensitive data detection across all fields of a bug or feature draft (`title`, `steps`, `expected`, `actual`, `problem`, `proposed`) before transmitting the draft to the backend for GitHub issue creation. If any sensitive data is detected, submission SHALL be hard-blocked. + +#### Scenario: Draft title contains a seed phrase keyword +- **WHEN** the user attempts to confirm submission of a draft whose `title` field contains a sensitive keyword (e.g. "Issue with my seed phrase") +- **THEN** the draft MUST NOT be transmitted to the backend +- **AND** a toast message SHALL explain that sensitive content was found in the draft +- **AND** `draftStatus` SHALL NOT be changed to `submitting` + +#### Scenario: Draft steps contain a BIP39 mnemonic sequence +- **WHEN** the user attempts to submit a draft whose `steps` array contains 8 or more consecutive BIP39 words across a single step entry +- **THEN** the draft MUST NOT be transmitted +- **AND** a toast message SHALL be shown + +#### Scenario: Clean draft is submitted normally +- **WHEN** the user submits a draft that contains no sensitive data patterns +- **THEN** the draft SHALL be transmitted to the backend normally and the existing submission flow SHALL proceed unchanged + +--- + +### Requirement: Provide a dedicated testable sensitive-data detection utility +The system SHALL implement sensitive data detection in a standalone utility module (`src/utils/helpAiSensitiveData.ts`) that is independent of any React component, enabling unit testing of all detection categories without mounting UI. + +#### Scenario: Utility detects mnemonic in isolation +- **WHEN** `detectSensitiveInput` is called with a string of 8+ consecutive BIP39 words +- **THEN** it SHALL return a result with `kind: 'mnemonic'` + +#### Scenario: Utility detects extended private key in isolation +- **WHEN** `detectSensitiveInput` is called with a string containing an xprv key +- **THEN** it SHALL return a result with `kind: 'extended_private_key'` + +#### Scenario: Utility returns null for clean input +- **WHEN** `detectSensitiveInput` is called with a benign support question +- **THEN** it SHALL return `null` diff --git a/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/tasks.md b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/tasks.md new file mode 100644 index 0000000000..552234d03b --- /dev/null +++ b/openspec/changes/archive/2026-05-25-help-ai-sensitive-input-guard/tasks.md @@ -0,0 +1,47 @@ +## 1. BIP39 Word Set Constant + +- [x] 1.1 Create `src/constants/bip39WordSet.ts` that exports `BIP39_WORD_SET: Set` built from the full 2048-word English BIP39 wordlist (sourced from `bip39.txt`) + +## 2. Sensitive Data Detection Utility + +- [x] 2.1 Create `src/utils/helpAiSensitiveData.ts` and define the exported type: `SensitiveDetectionResult = { kind: 'mnemonic' | 'extended_private_key' | 'extended_public_key' | 'wif_key' | 'keyword'; message: string }` +- [x] 2.2 Implement `detectKeywords(text: string)` — case-insensitive match for: `seed phrase`, `recovery phrase`, `backup phrase`, `secret phrase`, `mnemonic`, `private key`, `privatekey`, `secret key`, `xpriv` +- [x] 2.3 Implement `detectExtendedKeys(text: string)` — regex match for extended private key prefixes (`xprv|yprv|zprv|Xprv|Yprv|Zprv|tprv|uprv|vprv`) followed by 100+ base58 chars; returns `kind: 'extended_private_key'` +- [x] 2.4 Implement extended public key detection in `detectExtendedKeys` — same pattern for `xpub|ypub|zpub|Xpub|Ypub|Zpub|tpub|upub|vpub` followed by 100+ base58 chars; returns `kind: 'extended_public_key'` +- [x] 2.5 Implement `detectWIF(text: string)` — regex match for `\b[5KLc][1-9A-HJ-NP-Za-km-z]{49,51}\b`; returns `kind: 'wif_key'` +- [x] 2.6 Implement `detectMnemonic(text: string)` using `BIP39_WORD_SET` — tokenize input on whitespace, scan for longest consecutive run of tokens all found in the set, return `kind: 'mnemonic'` if run ≥ 8 +- [x] 2.7 Implement exported `detectSensitiveInput(text: string): SensitiveDetectionResult | null` — calls each detector in priority order (extended keys → WIF → mnemonic → keywords) and returns first match or `null` +- [x] 2.8 Implement exported `detectSensitiveInDraft(draft: HelpDraft): SensitiveDetectionResult | null` — concatenates all draft string fields (`title`, each entry in `steps`, `expected`, `actual`, `problem`, `proposed`) separated by spaces and calls `detectSensitiveInput` on the result + +## 3. Unit Tests + +- [x] 3.1 Create `tests/helpAiSensitiveData.test.ts` and write tests for `detectSensitiveInput`: + - Returns `kind: 'mnemonic'` for a valid 12-word BIP39 mnemonic + - Returns `kind: 'mnemonic'` for a valid 24-word BIP39 mnemonic + - Returns `kind: 'mnemonic'` for exactly 8 consecutive BIP39 words + - Returns `null` for 7 consecutive BIP39 words (below threshold) + - Returns `null` for a clean benign support question +- [x] 3.2 Write tests for extended key detection: + - Returns `kind: 'extended_private_key'` for a full `xprv...` key string + - Returns `kind: 'extended_private_key'` for `zprv...` and `tprv...` variants + - Returns `kind: 'extended_public_key'` for a full `xpub...` key string + - Returns `null` for the word "xpub" alone (no long base58 suffix) + - Returns `null` for the word "xprv" alone +- [x] 3.3 Write tests for WIF detection: + - Returns `kind: 'wif_key'` for a WIF key starting with `K` + - Returns `kind: 'wif_key'` for a WIF key starting with `5` +- [x] 3.4 Write tests for keyword detection: + - Returns `kind: 'keyword'` for messages containing `seed phrase`, `recovery phrase`, `mnemonic`, `private key` + - Returns `null` for a message containing only the word `passphrase` (no other sensitive pattern) +- [x] 3.5 Write tests for `detectSensitiveInDraft`: + - Returns a result when the `title` field contains a keyword phrase + - Returns a result when a `steps` entry contains a BIP39 mnemonic sequence + - Returns `null` for a clean draft with no sensitive content + +## 4. HelpAiChat Integration + +- [x] 4.1 In `HelpAiChat.tsx`: remove the `SENSITIVE_INPUT_PATTERN` constant and its import +- [x] 4.2 Import `detectSensitiveInput` from `src/utils/helpAiSensitiveData` +- [x] 4.3 In `sendToChat()`: replace the `SENSITIVE_INPUT_PATTERN.test(text)` guard with `detectSensitiveInput(text.trim())`, keeping the same blocking behavior (return early, show toast using `result.message`) +- [x] 4.4 Import `detectSensitiveInDraft` from `src/utils/helpAiSensitiveData` +- [x] 4.5 In `submitDraftIssue()`: add a `detectSensitiveInDraft(draft)` call as the first check after the null guard; if a result is returned, call `showToast(result.message)` and return early without dispatching `setHelpAiDraftStatus('submitting')` diff --git a/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/design.md b/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/design.md new file mode 100644 index 0000000000..cc1ef291f5 --- /dev/null +++ b/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/design.md @@ -0,0 +1,39 @@ +## Context + +Currently, the "Ask Keeper" CTA in the disclaimer modal navigates to an external webpage that is not yet ready, leading to a subpar user experience. Users expect immediate, in-app information about Ask Keeper, especially when encountering the disclaimer. The app uses React Native with Gluestack UI, Redux Toolkit, and React Navigation v6. All UI must follow DESIGN.md for consistency. + +## Goals / Non-Goals + +**Goals:** +- Provide an in-app "About Ask Keeper" info screen with clear, high-quality presentation of provided content. +- Replace the disclaimer modal CTA navigation to point to this new screen. +- Ensure the new screen matches the app’s design system and UX standards. +- Keep content inline or in a constants file (no localization). + +**Non-Goals:** +- No changes to backend, APIs, or external dependencies. +- No localization or multi-language support. +- No changes to Ask Keeper’s backend or feature set. + +## Decisions + +- **Screen Placement:** The new info screen will be added under src/screens/AskKeeperInfo/AskKeeperInfoScreen.tsx. +- **Navigation:** Update React Navigation stack (likely in src/navigation/Navigator.tsx) to include the new screen. The disclaimer modal CTA will navigate here instead of opening a web URL. +- **Content Source:** Content will be stored inline in the screen or in a new src/constants/AskKeeperInfo.ts file for maintainability. +- **UI:** Use Gluestack UI components, following DESIGN.md for layout, typography, and color. Include a header, scrollable content, and a close/back button. +- **Testing:** Add/extend Jest tests for navigation and screen rendering. + +## Risks / Trade-offs + +- [Risk] UI may diverge from design system if DESIGN.md is not followed → **Mitigation:** Review DESIGN.md and reuse existing components/styles. +- [Risk] Navigation regression if stack is misconfigured → **Mitigation:** Test navigation flows and fallback behavior. +- [Risk] Content may become outdated if not maintained → **Mitigation:** Store in a constants file for easy updates. + +## Migration Plan + +- Ship as a minor release; no data migration needed. Rollback is safe (revert navigation and remove screen/component). + +## Open Questions + +- Final content for Ask Keeper info (to be provided/confirmed). +- Should the screen be accessible from other locations in the app? diff --git a/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/proposal.md b/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/proposal.md new file mode 100644 index 0000000000..606b8a0466 --- /dev/null +++ b/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/proposal.md @@ -0,0 +1,26 @@ +## Why + +The current "Ask Keeper" CTA in the disclaimer modal navigates users to an external webpage that is not yet ready, resulting in a poor user experience and confusion. Providing an in-app "About Ask Keeper" info screen will deliver clear, immediate information about the feature, improve trust, and keep users within the app. + +## What Changes + +- Add a new "About Ask Keeper" info screen in the app, accessible from the disclaimer modal CTA. +- Replace the existing CTA navigation (currently to a not-ready webpage) with navigation to the new info screen. +- Display provided content about Ask Keeper on the new screen, with attention to UI quality and alignment with DESIGN.md. +- Content may be inline or in a constants file; no localization required. + +## Capabilities + +### New Capabilities +- `about-ask-keeper-info-screen`: In-app info screen presenting details about Ask Keeper, accessible from the disclaimer modal CTA. + +### Modified Capabilities +- (none) + +## Impact + +- Affects React Navigation stack (adding new screen, updating navigation target). +- Updates to disclaimer modal logic and UI. +- Adds new screen/component under src/screens/ or src/components/. +- May add new constants file for Ask Keeper info content. +- No backend/API or dependency changes required. diff --git a/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/specs/about-ask-keeper-info-screen/spec.md b/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/specs/about-ask-keeper-info-screen/spec.md new file mode 100644 index 0000000000..5ce9c2fe66 --- /dev/null +++ b/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/specs/about-ask-keeper-info-screen/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: In-app "About Ask Keeper" info screen +The app SHALL provide an in-app info screen titled "About Ask Keeper" that presents clear, high-quality information about the Ask Keeper feature. This screen SHALL be accessible from the disclaimer modal CTA and SHALL follow the app’s design system as defined in DESIGN.md. + +#### Scenario: Accessing the info screen from disclaimer modal +- **WHEN** the user taps the "Ask Keeper" CTA in the disclaimer modal +- **THEN** the app navigates to the "About Ask Keeper" info screen + +#### Scenario: Content display +- **WHEN** the "About Ask Keeper" info screen is shown +- **THEN** the provided Ask Keeper information is displayed with correct formatting, layout, and UI quality per DESIGN.md + +#### Scenario: Navigation back +- **WHEN** the user taps the back or close button on the info screen +- **THEN** the app returns to the previous screen or modal + +#### Scenario: No external navigation +- **WHEN** the user accesses the "About Ask Keeper" info screen +- **THEN** the app does NOT open an external browser or web page + +## MODIFIED Requirements + +(none) + +## REMOVED Requirements + +(none) diff --git a/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/tasks.md b/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/tasks.md new file mode 100644 index 0000000000..bebe472ba7 --- /dev/null +++ b/openspec/changes/archive/2026-05-26-about-ask-keeper-info-screen/tasks.md @@ -0,0 +1,25 @@ +## 1. Screen and Content Setup + +- [ ] 1.1 Create new screen folder: src/screens/AskKeeperInfo/ +- [ ] 1.2 Implement AskKeeperInfoScreen.tsx with header, scrollable content, and back/close button +- [ ] 1.3 Add Ask Keeper info content (inline or in src/constants/AskKeeperInfo.ts) + +## 2. Navigation Integration + +- [ ] 2.1 Add AskKeeperInfoScreen to React Navigation stack (src/navigation/Navigator.tsx) +- [ ] 2.2 Update disclaimer modal CTA to navigate to AskKeeperInfoScreen instead of external URL + +## 3. UI/UX Polish + +- [ ] 3.1 Ensure all UI matches DESIGN.md (typography, spacing, colors, etc.) +- [ ] 3.2 Reuse existing Gluestack UI components where possible + +## 4. Testing + +- [ ] 4.1 Add/extend Jest tests for navigation and AskKeeperInfoScreen rendering +- [ ] 4.2 Manually verify navigation and content display on both iOS and Android + +## 5. Documentation & Review + +- [ ] 5.1 Document Ask Keeper info content source for future updates +- [ ] 5.2 Peer review and design review for UI quality diff --git a/package.json b/package.json index 82fae51218..daf1e47ea7 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "release android dev": "cd android && ENVFILE=.env bundle exec fastlane dev --env dev", "release android prod": "cd android && ENVFILE=.env.production bundle exec fastlane live --env production", "release ios dev": "cd ios && ENVFILE=.env bundle exec fastlane dev --env dev", - "release ios prod": "cd ios && ENVFILE=.env.production bundle exec fastlane live --env production" + "release ios prod": "cd ios && ENVFILE=.env.production bundle exec fastlane live --env production", + "maestro:test:regression": "maestro test flows/regression.yaml" }, "dependencies": { "@bitcoinerlab/miniscript": "1.4.0", diff --git a/patches/react-native+0.83.9.patch b/patches/react-native+0.83.9.patch index 0c49420912..6eeb009e8a 100644 --- a/patches/react-native+0.83.9.patch +++ b/patches/react-native+0.83.9.patch @@ -1,3 +1,76 @@ +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +index 04aa6da..3120760 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +@@ -18,6 +18,7 @@ + #import + #import + #import ++#import + #import + #import + #import +@@ -134,13 +135,15 @@ + (ComponentDescriptorProvider)componentDescriptorProvider + + - (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index + { +- RCTAssert( +- childComponentView.superview == nil, +- @"Attempt to mount already mounted component view. (parent: %@, child: %@, index: %@, existing parent: %@)", +- self, +- childComponentView, +- @(index), +- @([childComponentView.superview tag])); ++ if (childComponentView.superview != nil) { ++ RCTLogWarn( ++ @"RCTViewComponentView: Skipping mount of already mounted component view. (parent: %@, child: %@, index: %@, existing parent: %@)", ++ self, ++ childComponentView, ++ @(index), ++ childComponentView.superview); ++ return; ++ } + + if (_removeClippedSubviews) { + [_reactSubviews insertObject:childComponentView atIndex:index]; +@@ -154,21 +157,22 @@ - (void)unmountChildComponentView:(UIView *)childCompo + if (_removeClippedSubviews) { + [_reactSubviews removeObjectAtIndex:index]; + } else { +- RCTAssert( +- childComponentView.superview == self.currentContainerView, +- @"Attempt to unmount a view which is mounted inside different view. (parent: %@, child: %@, index: %@)", +- self, +- childComponentView, +- @(index)); +- RCTAssert( +- (self.currentContainerView.subviews.count > index) && +- [self.currentContainerView.subviews objectAtIndex:index] == childComponentView, +- @"Attempt to unmount a view which has a different index. (parent: %@, child: %@, index: %@, actual index: %@, tag at index: %@)", +- self, +- childComponentView, +- @(index), +- @([self.currentContainerView.subviews indexOfObject:childComponentView]), +- @([[self.currentContainerView.subviews objectAtIndex:index] tag])); ++ if (childComponentView.superview != self.currentContainerView) { ++ RCTLogWarn( ++ @"RCTViewComponentView: Skipping unmount - view is mounted in a different parent. (parent: %@, child: %@, index: %@)", ++ self, ++ childComponentView, ++ @(index)); ++ } ++ if (self.currentContainerView.subviews.count > (NSUInteger)index && ++ [self.currentContainerView.subviews objectAtIndex:index] != childComponentView) { ++ RCTLogWarn( ++ @"RCTViewComponentView: Unmounting view at mismatched index. (parent: %@, child: %@, index: %@, actual index: %@)", ++ self, ++ childComponentView, ++ @(index), ++ @([self.currentContainerView.subviews indexOfObject:childComponentView])); ++ } + } + + [childComponentView removeFromSuperview]; diff --git a/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewRegistry.mm b/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewRegistry.mm index 0834bbf..b1aebf7 100644 --- a/node_modules/react-native/React/Fabric/Mounting/RCTComponentViewRegistry.mm diff --git a/src/assets/images/ask_keeper_info.svg b/src/assets/images/ask_keeper_info.svg new file mode 100644 index 0000000000..7e8dfde4d4 --- /dev/null +++ b/src/assets/images/ask_keeper_info.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/images/ask_keeper_lock.svg b/src/assets/images/ask_keeper_lock.svg new file mode 100644 index 0000000000..aed6183907 --- /dev/null +++ b/src/assets/images/ask_keeper_lock.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/images/ask_keeper_shield.svg b/src/assets/images/ask_keeper_shield.svg new file mode 100644 index 0000000000..b0501de329 --- /dev/null +++ b/src/assets/images/ask_keeper_shield.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/chat.svg b/src/assets/images/chat.svg new file mode 100644 index 0000000000..66f8363ea8 --- /dev/null +++ b/src/assets/images/chat.svg @@ -0,0 +1,13 @@ + + + \ No newline at end of file diff --git a/src/assets/images/chatBubble.svg b/src/assets/images/chatBubble.svg new file mode 100644 index 0000000000..16187bf743 --- /dev/null +++ b/src/assets/images/chatBubble.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/images/chatIcons/address.svg b/src/assets/images/chatIcons/address.svg new file mode 100644 index 0000000000..0f89404241 --- /dev/null +++ b/src/assets/images/chatIcons/address.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/chatIcons/alert.svg b/src/assets/images/chatIcons/alert.svg new file mode 100644 index 0000000000..a689d1492e --- /dev/null +++ b/src/assets/images/chatIcons/alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/chatIcons/chat.svg b/src/assets/images/chatIcons/chat.svg new file mode 100644 index 0000000000..41bfba36e1 --- /dev/null +++ b/src/assets/images/chatIcons/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/chatIcons/clock.svg b/src/assets/images/chatIcons/clock.svg new file mode 100644 index 0000000000..9658389aef --- /dev/null +++ b/src/assets/images/chatIcons/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/chatIcons/download.svg b/src/assets/images/chatIcons/download.svg new file mode 100644 index 0000000000..167e3b6ddf --- /dev/null +++ b/src/assets/images/chatIcons/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/chatIcons/fee.svg b/src/assets/images/chatIcons/fee.svg new file mode 100644 index 0000000000..0ce3f467e7 --- /dev/null +++ b/src/assets/images/chatIcons/fee.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/chatIcons/hardware.svg b/src/assets/images/chatIcons/hardware.svg new file mode 100644 index 0000000000..7ff0aa94a1 --- /dev/null +++ b/src/assets/images/chatIcons/hardware.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/chatIcons/inheritance.svg b/src/assets/images/chatIcons/inheritance.svg new file mode 100644 index 0000000000..f86e17b0f0 --- /dev/null +++ b/src/assets/images/chatIcons/inheritance.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/images/chatIcons/key.svg b/src/assets/images/chatIcons/key.svg new file mode 100644 index 0000000000..c897e3a210 --- /dev/null +++ b/src/assets/images/chatIcons/key.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/images/chatIcons/lightbulb.svg b/src/assets/images/chatIcons/lightbulb.svg new file mode 100644 index 0000000000..b0ab81f4b2 --- /dev/null +++ b/src/assets/images/chatIcons/lightbulb.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/chatIcons/link.svg b/src/assets/images/chatIcons/link.svg new file mode 100644 index 0000000000..ab207d3f78 --- /dev/null +++ b/src/assets/images/chatIcons/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/chatIcons/node.svg b/src/assets/images/chatIcons/node.svg new file mode 100644 index 0000000000..058088f6d6 --- /dev/null +++ b/src/assets/images/chatIcons/node.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/images/chatIcons/shield.svg b/src/assets/images/chatIcons/shield.svg new file mode 100644 index 0000000000..533a754ffe --- /dev/null +++ b/src/assets/images/chatIcons/shield.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/chatIcons/vault.svg b/src/assets/images/chatIcons/vault.svg new file mode 100644 index 0000000000..78e9781d46 --- /dev/null +++ b/src/assets/images/chatIcons/vault.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/images/chatIcons/wallet.svg b/src/assets/images/chatIcons/wallet.svg new file mode 100644 index 0000000000..e86d9d6ba8 --- /dev/null +++ b/src/assets/images/chatIcons/wallet.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/images/lockLightGreen.svg b/src/assets/images/lockLightGreen.svg new file mode 100644 index 0000000000..befe048d8b --- /dev/null +++ b/src/assets/images/lockLightGreen.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/Fab.tsx b/src/components/Fab.tsx new file mode 100644 index 0000000000..15b74b8e42 --- /dev/null +++ b/src/components/Fab.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { StyleSheet, Pressable } from 'react-native'; +import { wp } from 'src/constants/responsive'; +import Colors from 'src/theme/Colors'; + +interface FabProps { + icon: React.ReactNode; + onPress: () => void; + containerStyle?: object; +} + +const Fab = ({ icon, onPress, containerStyle }: FabProps) => { + return ( + + {icon} + + ); +}; + +const styles = StyleSheet.create({ + fab: { + height: wp(55), + width: wp(55), + borderRadius: wp(35), + backgroundColor: Colors.primaryGreen, + position: 'absolute', + bottom: wp(35), + right: wp(0), + elevation: 20, + alignItems: 'center', + justifyContent: 'center', + // ios + shadowColor: 'black', + shadowOffset: { width: 10, height: 20 }, + shadowOpacity: 0.3, + shadowRadius: 20, + zIndex: 1, + }, +}); + +export default Fab; diff --git a/src/components/HomeScreenHeader.tsx b/src/components/HomeScreenHeader.tsx index a7c4ff209d..ac9727a035 100644 --- a/src/components/HomeScreenHeader.tsx +++ b/src/components/HomeScreenHeader.tsx @@ -31,12 +31,14 @@ interface HomeScreenHeaderProps { colorMode: string; circleIconWrapper: React.ReactNode; title: string; + titleSuffix?: string; } const HomeScreenHeader: React.FC = ({ colorMode, circleIconWrapper, title, + titleSuffix, }) => { const navigation = useNavigation(); const dispatch = useDispatch(); @@ -166,6 +168,15 @@ const HomeScreenHeader: React.FC = ({ }, }; + + const getHeaderTitle = useCallback( + (title: string) => { + if (title === walletTranslation.more) title = common.moreOptions; + return capitalizeEachWord(title); + }, + [title, walletTranslation.more, common.moreOptions] + ); + return ( @@ -178,7 +189,7 @@ const HomeScreenHeader: React.FC = ({ color={`${colorMode}.headerWhite`} medium > - {capitalizeEachWord(title === walletTranslation.more ? common.moreOptions : title)} + {getHeaderTitle(title)}{titleSuffix ?? ''} diff --git a/src/components/KeeperModal.tsx b/src/components/KeeperModal.tsx index 14fd7cc111..8f5105c2a8 100644 --- a/src/components/KeeperModal.tsx +++ b/src/components/KeeperModal.tsx @@ -45,6 +45,7 @@ type ModalProps = { loading?: boolean; secondaryIcon?: any; disable?: boolean; + centerTitle?: boolean; }; function KeeperModal(props: ModalProps) { @@ -76,6 +77,7 @@ function KeeperModal(props: ModalProps) { loading = false, secondaryIcon = null, disable = false, + centerTitle = false, } = props; const subTitleColor = ignored || textColor; const { bottom } = useSafeAreaInsets(); @@ -130,7 +132,9 @@ function KeeperModal(props: ModalProps) { )} {title || subTitle ? ( - + }, headerContainer: { flexDirection: 'column', - alignSelf: 'flex-start', + borderBottomWidth: 0, backgroundColor: 'transparent', marginTop: wp(5), diff --git a/src/components/MenuFooter.tsx b/src/components/MenuFooter.tsx index 52d8ccbc52..6547ec6923 100644 --- a/src/components/MenuFooter.tsx +++ b/src/components/MenuFooter.tsx @@ -15,7 +15,7 @@ const MenuFooter = ({ selectedOption, onOptionChange }) => { const { colorMode } = useColorMode(); const isDarkMode = colorMode === 'dark'; const { translations } = useContext(LocalizationContext); - const { wallet: walletTranslation } = translations; + const { wallet: walletTranslation, askAi } = translations; const selectedFooterColor = ThemedColor({ name: 'footer_selected_option' }); const menuOptions = [ @@ -30,7 +30,7 @@ const MenuFooter = ({ selectedOption, onOptionChange }) => { selectedIcon: , }, { - name: walletTranslation.concierge, + name: askAi.ask, defaultIcon: , selectedIcon: , }, diff --git a/src/components/ToastMessages.tsx b/src/components/ToastMessages.tsx index b36ba97193..fc8d53449b 100644 --- a/src/components/ToastMessages.tsx +++ b/src/components/ToastMessages.tsx @@ -6,6 +6,9 @@ import { windowWidth, hp, wp } from 'src/constants/responsive'; import ToastErrorIcon from 'src/assets/images/toast_error.svg'; import Text from './KeeperText'; +const TOAST_MESSAGE_TEST_ID = 'toast_message'; +const TOAST_MESSAGE_TEXT_TEST_ID = 'toast_message_text'; + function HexaToastMessages({ Image = null, error = false, @@ -17,9 +20,18 @@ function HexaToastMessages({ }) { const { colorMode } = useColorMode(); return ( - + {error ? : Image ? {Image} : null} = ({ )} {title && ( - + {title} )} {learnMore && ( - + )} {rightComponent && {rightComponent}} {subTitle && ( - + {subTitle} )} diff --git a/src/constants/AskKeeperInfo.ts b/src/constants/AskKeeperInfo.ts new file mode 100644 index 0000000000..95a7181869 --- /dev/null +++ b/src/constants/AskKeeperInfo.ts @@ -0,0 +1,42 @@ +// About Ask Keeper info content for the info screen +export const ASK_KEEPER_INFO = { + title: 'About Ask Keeper', + sections: [ + { + heading: 'What is Ask Keeper?', + body: `Ask Keeper is an AI-assisted help system built for Bitcoin Keeper. It can help answer questions, guide users, collect bug reports, and draft feature requests.` + }, + { + heading: 'What Ask Keeper can do', + body: ` • Answer common Keeper questions\n • Help troubleshoot issues\n • Draft GitHub bug reports\n • Draft feature requests\n • Guide users toward support resources` + }, + { + heading: 'What Ask Keeper cannot do', + body: `Ask Keeper cannot:\n • access your wallet\n • sign transactions\n • recover funds\n • view your keys\n • perform actions on your behalf` + }, + { + heading: 'Important safety notes', + body: `Never share:\n • seed words\n • private keys\n • passphrases\n • wallet backups\n • sensitive personal information\nKeeper support will never ask for these.` + }, + { + heading: 'AI limitations', + body: `Ask Keeper may sometimes:\n • misunderstand questions\n • provide incomplete answers\n • make mistakes\nAlways verify important wallet or recovery actions carefully.` + }, + { + heading: 'Privacy', + body: `Ask Keeper sends limited chat context and app metadata to Keeper services to generate responses.\nMessages may be processed by AI systems operated by Keeper or its service providers.\nSensitive information should never be shared in chat.` + }, + { + heading: 'GitHub reports', + body: `If you choose to submit a bug report or feature request, the report may be posted publicly on GitHub after confirmation.\nYou will always be shown the draft before submission.` + }, + { + heading: 'Human support', + body: `If Ask Keeper cannot help, it may guide you toward:\n • community support\n • advisors\n • support email\nAlways be cautious of scammers or impersonators in public communities.` + }, + { + heading: 'Open source', + body: `Bitcoin Keeper is open source and community-driven.\nIf you find Ask Keeper useful, you can support development through the app Settings section.` + } + ] +}; diff --git a/src/constants/ChatAiGreetings.ts b/src/constants/ChatAiGreetings.ts new file mode 100644 index 0000000000..4212742498 --- /dev/null +++ b/src/constants/ChatAiGreetings.ts @@ -0,0 +1,12 @@ +export const GREETINGS = [ + "Hey there! I'm Keeper Help, your Bitcoin Keeper assistant. How can I help you today?", + "Welcome back! Need help with your vault, wallet setup, or anything Bitcoin Keeper? I'm here.", + "Hi! I'm Keeper Help. Whether it's keys, backups, or transactions — ask me anything.", + "Hello! Managing your Bitcoin shouldn't be hard. I'm here to make Keeper simple for you.", + "Hey! Got a question about self-custody, signers, or your Keeper setup? Let's figure it out together.", + "Welcome to Keeper Help! Your sats, your keys — and I'm here to help you manage them confidently.", + "Hi there! I'm your in-app guide for Bitcoin Keeper. What would you like to do today?", + "Hey! Whether you're setting up a new vault or recovering a wallet, I've got you covered.", + "Hello! I'm Keeper Help — your go-to for anything inside Bitcoin Keeper. What's on your mind?", + "Welcome! Self-custody is powerful — and I'm here to make sure you get the most out of Bitcoin Keeper.", +]; diff --git a/src/constants/bip39WordSet.ts b/src/constants/bip39WordSet.ts new file mode 100644 index 0000000000..2decc20788 --- /dev/null +++ b/src/constants/bip39WordSet.ts @@ -0,0 +1,265 @@ +/** + * BIP39 English wordlist as a Set for O(1) membership testing. + * Used by the sensitive-input guard to detect mnemonic sequences in chat. + * Source: BIP39 specification – 2048 words, immutable Bitcoin standard. + */ +const BIP39_WORDS: string[] = [ + 'abandon','ability','able','about','above','absent','absorb','abstract', + 'absurd','abuse','access','accident','account','accuse','achieve','acid', + 'acoustic','acquire','across','act','action','actor','actress','actual', + 'adapt','add','addict','address','adjust','admit','adult','advance', + 'advice','aerobic','affair','afford','afraid','again','age','agent', + 'agree','ahead','aim','air','airport','aisle','alarm','album', + 'alcohol','alert','alien','all','alley','allow','almost','alone', + 'alpha','already','also','alter','always','amateur','amazing','among', + 'amount','amused','analyst','anchor','ancient','anger','angle','angry', + 'animal','ankle','announce','annual','another','answer','antenna','antique', + 'anxiety','any','apart','apology','appear','apple','approve','april', + 'arch','arctic','area','arena','argue','arm','armed','armor', + 'army','around','arrange','arrest','arrive','arrow','art','artefact', + 'artist','artwork','ask','aspect','assault','asset','assist','assume', + 'asthma','athlete','atom','attack','attend','attitude','attract','auction', + 'audit','august','aunt','author','auto','autumn','average','avocado', + 'avoid','awake','aware','away','awesome','awful','awkward','axis', + 'baby','bachelor','bacon','badge','bag','balance','balcony','ball', + 'bamboo','banana','banner','bar','barely','bargain','barrel','base', + 'basic','basket','battle','beach','bean','beauty','because','become', + 'beef','before','begin','behave','behind','believe','below','belt', + 'bench','benefit','best','betray','better','between','beyond','bicycle', + 'bid','bike','bind','biology','bird','birth','bitter','black', + 'blade','blame','blanket','blast','bleak','bless','blind','blood', + 'blossom','blouse','blue','blur','blush','board','boat','body', + 'boil','bomb','bone','bonus','book','boost','border','boring', + 'borrow','boss','bottom','bounce','box','boy','bracket','brain', + 'brand','brass','brave','bread','breeze','brick','bridge','brief', + 'bright','bring','brisk','broccoli','broken','bronze','broom','brother', + 'brown','brush','bubble','buddy','budget','buffalo','build','bulb', + 'bulk','bullet','bundle','bunker','burden','burger','burst','bus', + 'business','busy','butter','buyer','buzz','cabbage','cabin','cable', + 'cactus','cage','cake','call','calm','camera','camp','can', + 'canal','cancel','candy','cannon','canoe','canvas','canyon','capable', + 'capital','captain','car','carbon','card','cargo','carpet','carry', + 'cart','case','cash','casino','castle','casual','cat','catalog', + 'catch','category','cattle','caught','cause','caution','cave','ceiling', + 'celery','cement','census','century','cereal','certain','chair','chalk', + 'champion','change','chaos','chapter','charge','chase','chat','cheap', + 'check','cheese','chef','cherry','chest','chicken','chief','child', + 'chimney','choice','choose','chronic','chuckle','chunk','churn','cigar', + 'cinnamon','circle','citizen','city','civil','claim','clap','clarify', + 'claw','clay','clean','clerk','clever','click','client','cliff', + 'climb','clinic','clip','clock','clog','close','cloth','cloud', + 'clown','club','clump','cluster','clutch','coach','coast','coconut', + 'code','coffee','coil','coin','collect','color','column','combine', + 'come','comfort','comic','common','company','concert','conduct','confirm', + 'congress','connect','consider','control','convince','cook','cool','copper', + 'copy','coral','core','corn','correct','cost','cotton','couch', + 'country','couple','course','cousin','cover','coyote','crack','cradle', + 'craft','cram','crane','crash','crater','crawl','crazy','cream', + 'credit','creek','crew','cricket','crime','crisp','critic','crop', + 'cross','crouch','crowd','crucial','cruel','cruise','crumble','crunch', + 'crush','cry','crystal','cube','culture','cup','cupboard','curious', + 'current','curtain','curve','cushion','custom','cute','cycle','dad', + 'damage','damp','dance','danger','daring','dash','daughter','dawn', + 'day','deal','debate','debris','decade','december','decide','decline', + 'decorate','decrease','deer','defense','define','defy','degree','delay', + 'deliver','demand','demise','denial','dentist','deny','depart','depend', + 'deposit','depth','deputy','derive','describe','desert','design','desk', + 'despair','destroy','detail','detect','develop','device','devote','diagram', + 'dial','diamond','diary','dice','diesel','diet','differ','digital', + 'dignity','dilemma','dinner','dinosaur','direct','dirt','disagree','discover', + 'disease','dish','dismiss','disorder','display','distance','divert','divide', + 'divorce','dizzy','doctor','document','dog','doll','dolphin','domain', + 'donate','donkey','donor','door','dose','double','dove','draft', + 'dragon','drama','drastic','draw','dream','dress','drift','drill', + 'drink','drip','drive','drop','drum','dry','duck','dumb', + 'dune','during','dust','dutch','duty','dwarf','dynamic','eager', + 'eagle','early','earn','earth','easily','east','easy','echo', + 'ecology','economy','edge','edit','educate','effort','egg','eight', + 'either','elbow','elder','electric','elegant','element','elephant','elevator', + 'elite','else','embark','embody','embrace','emerge','emotion','employ', + 'empower','empty','enable','enact','end','endless','endorse','enemy', + 'energy','enforce','engage','engine','enhance','enjoy','enlist','enough', + 'enrich','enroll','ensure','enter','entire','entry','envelope','episode', + 'equal','equip','era','erase','erode','erosion','error','erupt', + 'escape','essay','essence','estate','eternal','ethics','evidence','evil', + 'evoke','evolve','exact','example','excess','exchange','excite','exclude', + 'excuse','execute','exercise','exhaust','exhibit','exile','exist','exit', + 'exotic','expand','expect','expire','explain','expose','express','extend', + 'extra','eye','eyebrow','fabric','face','faculty','fade','faint', + 'faith','fall','false','fame','family','famous','fan','fancy', + 'fantasy','farm','fashion','fat','fatal','father','fatigue','fault', + 'favorite','feature','february','federal','fee','feed','feel','female', + 'fence','festival','fetch','fever','few','fiber','fiction','field', + 'figure','file','film','filter','final','find','fine','finger', + 'finish','fire','firm','first','fiscal','fish','fit','fitness', + 'fix','flag','flame','flash','flat','flavor','flee','flight', + 'flip','float','flock','floor','flower','fluid','flush','fly', + 'foam','focus','fog','foil','fold','follow','food','foot', + 'force','forest','forget','fork','fortune','forum','forward','fossil', + 'foster','found','fox','fragile','frame','frequent','fresh','friend', + 'fringe','frog','front','frost','frown','frozen','fruit','fuel', + 'fun','funny','furnace','fury','future','gadget','gain','galaxy', + 'gallery','game','gap','garage','garbage','garden','garlic','garment', + 'gas','gasp','gate','gather','gauge','gaze','general','genius', + 'genre','gentle','genuine','gesture','ghost','giant','gift','giggle', + 'ginger','giraffe','girl','give','glad','glance','glare','glass', + 'glide','glimpse','globe','gloom','glory','glove','glow','glue', + 'goat','goddess','gold','good','goose','gorilla','gospel','gossip', + 'govern','gown','grab','grace','grain','grant','grape','grass', + 'gravity','great','green','grid','grief','grit','grocery','group', + 'grow','grunt','guard','guess','guide','guilt','guitar','gun', + 'gym','habit','hair','half','hammer','hamster','hand','happy', + 'harbor','hard','harsh','harvest','hat','have','hawk','hazard', + 'head','health','heart','heavy','hedgehog','height','hello','helmet', + 'help','hen','hero','hidden','high','hill','hint','hip', + 'hire','history','hobby','hockey','hold','hole','holiday','hollow', + 'home','honey','hood','hope','horn','horror','horse','hospital', + 'host','hotel','hour','hover','hub','huge','human','humble', + 'humor','hundred','hungry','hunt','hurdle','hurry','hurt','husband', + 'hybrid','ice','icon','idea','identify','idle','ignore','ill', + 'illegal','illness','image','imitate','immense','immune','impact','impose', + 'improve','impulse','inch','include','income','increase','index','indicate', + 'indoor','industry','infant','inflict','inform','inhale','inherit','initial', + 'inject','injury','inmate','inner','innocent','input','inquiry','insane', + 'insect','inside','inspire','install','intact','interest','into','invest', + 'invite','involve','iron','island','isolate','issue','item','ivory', + 'jacket','jaguar','jar','jazz','jealous','jeans','jelly','jewel', + 'job','join','joke','journey','joy','judge','juice','jump', + 'jungle','junior','junk','just','kangaroo','keen','keep','ketchup', + 'key','kick','kid','kidney','kind','kingdom','kiss','kit', + 'kitchen','kite','kitten','kiwi','knee','knife','knock','know', + 'lab','label','labor','ladder','lady','lake','lamp','language', + 'laptop','large','later','latin','laugh','laundry','lava','law', + 'lawn','lawsuit','layer','lazy','leader','leaf','learn','leave', + 'lecture','left','leg','legal','legend','leisure','lemon','lend', + 'length','lens','leopard','lesson','letter','level','liar','liberty', + 'library','license','life','lift','light','like','limb','limit', + 'link','lion','liquid','list','little','live','lizard','load', + 'loan','lobster','local','lock','logic','lonely','long','loop', + 'lottery','loud','lounge','love','loyal','lucky','luggage','lumber', + 'lunar','lunch','luxury','lyrics','machine','mad','magic','magnet', + 'maid','mail','main','major','make','mammal','man','manage', + 'mandate','mango','mansion','manual','maple','marble','march','margin', + 'marine','market','marriage','mask','mass','master','match','material', + 'math','matrix','matter','maximum','maze','meadow','mean','measure', + 'meat','mechanic','medal','media','melody','melt','member','memory', + 'mention','menu','mercy','merge','merit','merry','mesh','message', + 'metal','method','middle','midnight','milk','million','mimic','mind', + 'minimum','minor','minute','miracle','mirror','misery','miss','mistake', + 'mix','mixed','mixture','mobile','model','modify','mom','moment', + 'monitor','monkey','monster','month','moon','moral','more','morning', + 'mosquito','mother','motion','motor','mountain','mouse','move','movie', + 'much','muffin','mule','multiply','muscle','museum','mushroom','music', + 'must','mutual','myself','mystery','myth','naive','name','napkin', + 'narrow','nasty','nation','nature','near','neck','need','negative', + 'neglect','neither','nephew','nerve','nest','net','network','neutral', + 'never','news','next','nice','night','noble','noise','nominee', + 'noodle','normal','north','nose','notable','note','nothing','notice', + 'novel','now','nuclear','number','nurse','nut','oak','obey', + 'object','oblige','obscure','observe','obtain','obvious','occur','ocean', + 'october','odor','off','offer','office','often','oil','okay', + 'old','olive','olympic','omit','once','one','onion','online', + 'only','open','opera','opinion','oppose','option','orange','orbit', + 'orchard','order','ordinary','organ','orient','original','orphan','ostrich', + 'other','outdoor','outer','output','outside','oval','oven','over', + 'own','owner','oxygen','oyster','ozone','pact','paddle','page', + 'pair','palace','palm','panda','panel','panic','panther','paper', + 'parade','parent','park','parrot','party','pass','patch','path', + 'patient','patrol','pattern','pause','pave','payment','peace','peanut', + 'pear','peasant','pelican','pen','penalty','pencil','people','pepper', + 'perfect','permit','person','pet','phone','photo','phrase','physical', + 'piano','picnic','picture','piece','pig','pigeon','pill','pilot', + 'pink','pioneer','pipe','pistol','pitch','pizza','place','planet', + 'plastic','plate','play','please','pledge','pluck','plug','plunge', + 'poem','poet','point','polar','pole','police','pond','pony', + 'pool','popular','portion','position','possible','post','potato','pottery', + 'poverty','powder','power','practice','praise','predict','prefer','prepare', + 'present','pretty','prevent','price','pride','primary','print','priority', + 'prison','private','prize','problem','process','produce','profit','program', + 'project','promote','proof','property','prosper','protect','proud','provide', + 'public','pudding','pull','pulp','pulse','pumpkin','punch','pupil', + 'puppy','purchase','purity','purpose','purse','push','put','puzzle', + 'pyramid','quality','quantum','quarter','question','quick','quit','quiz', + 'quote','rabbit','raccoon','race','rack','radar','radio','rail', + 'rain','raise','rally','ramp','ranch','random','range','rapid', + 'rare','rate','rather','raven','raw','razor','ready','real', + 'reason','rebel','rebuild','recall','receive','recipe','record','recycle', + 'reduce','reflect','reform','refuse','region','regret','regular','reject', + 'relax','release','relief','rely','remain','remember','remind','remove', + 'render','renew','rent','reopen','repair','repeat','replace','report', + 'require','rescue','resemble','resist','resource','response','result','retire', + 'retreat','return','reunion','reveal','review','reward','rhythm','rib', + 'ribbon','rice','rich','ride','ridge','rifle','right','rigid', + 'ring','riot','ripple','risk','ritual','rival','river','road', + 'roast','robot','robust','rocket','romance','roof','rookie','room', + 'rose','rotate','rough','round','route','royal','rubber','rude', + 'rug','rule','run','runway','rural','sad','saddle','sadness', + 'safe','sail','salad','salmon','salon','salt','salute','same', + 'sample','sand','satisfy','satoshi','sauce','sausage','save','say', + 'scale','scan','scare','scatter','scene','scheme','school','science', + 'scissors','scorpion','scout','scrap','screen','script','scrub','sea', + 'search','season','seat','second','secret','section','security','seed', + 'seek','segment','select','sell','seminar','senior','sense','sentence', + 'series','service','session','settle','setup','seven','shadow','shaft', + 'shallow','share','shed','shell','sheriff','shield','shift','shine', + 'ship','shiver','shock','shoe','shoot','shop','short','shoulder', + 'shove','shrimp','shrug','shuffle','shy','sibling','sick','side', + 'siege','sight','sign','silent','silk','silly','silver','similar', + 'simple','since','sing','siren','sister','situate','six','size', + 'skate','sketch','ski','skill','skin','skirt','skull','slab', + 'slam','sleep','slender','slice','slide','slight','slim','slogan', + 'slot','slow','slush','small','smart','smile','smoke','smooth', + 'snack','snake','snap','sniff','snow','soap','soccer','social', + 'sock','soda','soft','solar','soldier','solid','solution','solve', + 'someone','song','soon','sorry','sort','soul','sound','soup', + 'source','south','space','spare','spatial','spawn','speak','special', + 'speed','spell','spend','sphere','spice','spider','spike','spin', + 'spirit','split','spoil','sponsor','spoon','sport','spot','spray', + 'spread','spring','spy','square','squeeze','squirrel','stable','stadium', + 'staff','stage','stairs','stamp','stand','start','state','stay', + 'steak','steel','stem','step','stereo','stick','still','sting', + 'stock','stomach','stone','stool','story','stove','strategy','street', + 'strike','strong','struggle','student','stuff','stumble','style','subject', + 'submit','subway','success','such','sudden','suffer','sugar','suggest', + 'suit','summer','sun','sunny','sunset','super','supply','supreme', + 'sure','surface','surge','surprise','surround','survey','suspect','sustain', + 'swallow','swamp','swap','swarm','swear','sweet','swift','swim', + 'swing','switch','sword','symbol','symptom','syrup','system','table', + 'tackle','tag','tail','talent','talk','tank','tape','target', + 'task','taste','tattoo','taxi','teach','team','tell','ten', + 'tenant','tennis','tent','term','test','text','thank','that', + 'theme','then','theory','there','they','thing','this','thought', + 'three','thrive','throw','thumb','thunder','ticket','tide','tiger', + 'tilt','timber','time','tiny','tip','tired','tissue','title', + 'toast','tobacco','today','toddler','toe','together','toilet','token', + 'tomato','tomorrow','tone','tongue','tonight','tool','tooth','top', + 'topic','topple','torch','tornado','tortoise','toss','total','tourist', + 'toward','tower','town','toy','track','trade','traffic','tragic', + 'train','transfer','trap','trash','travel','tray','treat','tree', + 'trend','trial','tribe','trick','trigger','trim','trip','trophy', + 'trouble','truck','true','truly','trumpet','trust','truth','try', + 'tube','tuition','tumble','tuna','tunnel','turkey','turn','turtle', + 'twelve','twenty','twice','twin','twist','two','type','typical', + 'ugly','umbrella','unable','unaware','uncle','uncover','under','undo', + 'unfair','unfold','unhappy','uniform','unique','unit','universe','unknown', + 'unlock','until','unusual','unveil','update','upgrade','uphold','upon', + 'upper','upset','urban','urge','usage','use','used','useful', + 'useless','usual','utility','vacant','vacuum','vague','valid','valley', + 'valve','van','vanish','vapor','various','vast','vault','vehicle', + 'velvet','vendor','venture','venue','verb','verify','version','very', + 'vessel','veteran','viable','vibrant','vicious','victory','video','view', + 'village','vintage','violin','virtual','virus','visa','visit','visual', + 'vital','vivid','vocal','voice','void','volcano','volume','vote', + 'voyage','wage','wagon','wait','walk','wall','walnut','want', + 'warfare','warm','warrior','wash','wasp','waste','water','wave', + 'way','wealth','weapon','wear','weasel','weather','web','wedding', + 'weekend','weird','welcome','west','wet','whale','what','wheat', + 'wheel','when','where','whip','whisper','wide','width','wife', + 'wild','will','win','window','wine','wing','wink','winner', + 'winter','wire','wisdom','wise','wish','witness','wolf','woman', + 'wonder','wood','wool','word','work','world','worry','worth', + 'wrap','wreck','wrestle','wrist','write','wrong','yard','year', + 'yellow','you','young','youth','zebra','zero','zone','zoo', +]; + +export const BIP39_WORD_SET: Set = new Set(BIP39_WORDS); diff --git a/src/constants/helpAiEscalation.ts b/src/constants/helpAiEscalation.ts new file mode 100644 index 0000000000..91d0fc1803 --- /dev/null +++ b/src/constants/helpAiEscalation.ts @@ -0,0 +1,9 @@ +export const HELP_AI_ESCALATION_TELEGRAM_URL = 'https://t.me/bitcoinkeeper'; +export const HELP_AI_ESCALATION_ADVISOR_ROUTE = 'Advisors'; +export const HELP_AI_ESCALATION_DEV_EMAIL = 'hello@bithyve.com'; +export const HELP_AI_DISCLAIMER_STORAGE_KEY = 'help_ai_disclaimer_seen'; +export const HELP_AI_LEARN_MORE_URL = 'https://bitcoinkeeper.app/ask'; + +export const HELP_AI_ESCALATION_DEV_EMAIL_SUBJECT = 'Keeper Help escalation'; +export const HELP_AI_ESCALATION_DEV_EMAIL_BODY = + 'Hello Keeper team,\n\nI still need help with an issue in the app.\n\nThanks.'; diff --git a/src/constants/helpAiIcons.ts b/src/constants/helpAiIcons.ts new file mode 100644 index 0000000000..c0a55bce48 --- /dev/null +++ b/src/constants/helpAiIcons.ts @@ -0,0 +1,49 @@ +import type { HelpIconType } from 'src/models/interfaces/HelpAi'; +import ChatIcon from 'src/assets/images/chatIcons/chat.svg'; +import ClockIcon from 'src/assets/images/chatIcons/clock.svg'; +import DownloadIcon from 'src/assets/images/chatIcons/download.svg'; +import LinkIcon from 'src/assets/images/chatIcons/link.svg'; +import LightbulbIcon from 'src/assets/images/chatIcons/lightbulb.svg'; +import ShieldIcon from 'src/assets/images/chatIcons/shield.svg'; +import KeyIcon from 'src/assets/images/chatIcons/key.svg'; +import WalletIcon from 'src/assets/images/chatIcons/wallet.svg'; +import AlertIcon from 'src/assets/images/chatIcons/alert.svg'; +import HardwareIcon from 'src/assets/images/chatIcons/hardware.svg'; +import InheritanceIcon from 'src/assets/images/chatIcons/inheritance.svg'; +import VaultIcon from 'src/assets/images/chatIcons/vault.svg'; +import FeeIcon from 'src/assets/images/chatIcons/fee.svg'; +import AddressIcon from 'src/assets/images/chatIcons/address.svg'; +import NodeIcon from 'src/assets/images/chatIcons/node.svg'; + +export const HELP_AI_ICON_MAP: Record< + HelpIconType, + React.ComponentType<{ width?: number; height?: number }> +> = { + chat: ChatIcon, + clock: ClockIcon, + download: DownloadIcon, + link: LinkIcon, + lightbulb: LightbulbIcon, + shield: ShieldIcon, + key: KeyIcon, + wallet: WalletIcon, + alert: AlertIcon, + hardware: HardwareIcon, + inheritance: InheritanceIcon, + vault: VaultIcon, + fee: FeeIcon, + address: AddressIcon, + node: NodeIcon, +}; + +export function getThreadIconType( + messages: Array<{ type: string; iconType?: HelpIconType }> = [] +): HelpIconType { + for (let idx = messages.length - 1; idx >= 0; idx -= 1) { + const item = messages[idx]; + if (item?.type === 'ai' && item.iconType && item.iconType in HELP_AI_ICON_MAP) { + return item.iconType as HelpIconType; + } + } + return 'chat'; +} diff --git a/src/context/Localization/language/en.json b/src/context/Localization/language/en.json index 832d46d5b9..fa4879e106 100644 --- a/src/context/Localization/language/en.json +++ b/src/context/Localization/language/en.json @@ -181,7 +181,8 @@ "disclaimer": "Keeper is a community-led, open-source bitcoin wallet. It does not provide financial or investment advice.", "maybeLater": "Maybe later", "noThanks": "No Thanks", - "export": "Export" + "export": "Export", + "help": "Help" }, "login": { "welcomeback": "Welcome Back", @@ -2157,5 +2158,19 @@ "importInfoDesc1": "Bitcoin Keeper supports importing GasFree USDT wallets only. If you're using a compatible wallet provider that also supports gas-free transactions on the Tron network, you can import your wallet using your 12 or 24-word recovery phrase.", "importInfoDesc2": "Once imported, you’ll be able to access and manage your USDT without needing TRX.", "importInfoDescNote": "Note: Standard TRC-20 wallets that require TRX to move funds are not supported." + }, + "askAi": { + "ask": "Ask", + "askKeeper": "Ask Keeper", + "noConversationsYet": "No conversations yet", + "subtitle": "Get help, report a problem, or share an idea.", + "openHelpChat": "Start a conversation", + "neverShareSeedWarning": "Never share seed words or private keys.", + "disclaimerTitle": "Before you use Ask Keeper", + "disclaimerPoint1": "Never share seed words, private keys, or wallet secrets.", + "disclaimerPoint2": "Ask Keeper may sometimes be incorrect or incomplete.", + "disclaimerPoint3": "Your messages are processed securely to help Ask Keeper respond.", + "disclaimerLearnMore": "Learn more about Ask Keeper", + "disclaimerCta": "Got it" } } \ No newline at end of file diff --git a/src/context/Localization/language/es.json b/src/context/Localization/language/es.json index 27a3be913a..2997e1f177 100644 --- a/src/context/Localization/language/es.json +++ b/src/context/Localization/language/es.json @@ -181,7 +181,8 @@ "disclaimer": "Keeper is a community-led, open-source bitcoin wallet. It does not provide financial or investment advice.", "maybeLater": "Maybe later", "noThanks": "No Thanks", - "export": "Export" + "export": "Export", + "help": "Help" }, "login": { "welcomeback": "Welcome Back", @@ -2150,5 +2151,19 @@ "importInfoDesc1": "Bitcoin Keeper supports importing GasFree USDT wallets only. If you're using a compatible wallet provider that also supports gas-free transactions on the Tron network, you can import your wallet using your 12 or 24-word recovery phrase.", "importInfoDesc2": "Once imported, you’ll be able to access and manage your USDT without needing TRX.", "importInfoDescNote": "Note: Standard TRC-20 wallets that require TRX to move funds are not supported." + }, + "askAi": { + "ask": "Ask", + "askKeeper": "Ask Keeper", + "noConversationsYet": "No conversations yet", + "subtitle": "Get help, report a problem, or share an idea.", + "openHelpChat": "Start a conversation", + "neverShareSeedWarning": "Never share seed words or private keys.", + "disclaimerTitle": "Before you use Ask Keeper", + "disclaimerPoint1": "Never share seed words, private keys, or wallet secrets.", + "disclaimerPoint2": "Ask Keeper may sometimes be incorrect or incomplete.", + "disclaimerPoint3": "Your messages are processed securely to help Ask Keeper respond.", + "disclaimerLearnMore": "Learn more about Ask Keeper", + "disclaimerCta": "Got it" } } \ No newline at end of file diff --git a/src/hooks/useSettingKeeper.tsx b/src/hooks/useSettingKeeper.tsx index ecb2afb292..d9242915b3 100644 --- a/src/hooks/useSettingKeeper.tsx +++ b/src/hooks/useSettingKeeper.tsx @@ -31,6 +31,7 @@ import KeeperPrivateIconWhite from 'src/assets/images/KeeperPrivateIconWhite.svg import PrivateManageWallet from 'src/assets/privateImages/manage-wallet-icon.svg'; import MultiUserIcon from 'src/assets/images/MultiUserIcon.svg'; import InheritanceDocumentIcon from 'src/assets/images/inheritanceDocumentIcon.svg'; +import ConciergeWhite from 'src/assets/images/faqWhiteIcon.svg'; import Switch from 'src/components/Switch/Switch'; import { LocalizationContext } from 'src/context/Localization/LocContext'; @@ -274,6 +275,13 @@ export const useSettingKeeper = () => { ]; const General = [ + { + title: 'Advisors', + description: 'Get expert advice', + icon: , + onPress: () => navigation.navigate('Advisors'), + isDiamond: false, + }, { title: settings.DarkMode, description: settings.DarkModeSubTitle, diff --git a/src/hooks/useToastMessage.tsx b/src/hooks/useToastMessage.tsx index 1561e014f6..44c7bdf090 100644 --- a/src/hooks/useToastMessage.tsx +++ b/src/hooks/useToastMessage.tsx @@ -8,6 +8,9 @@ export enum IToastCategory { DEFAULT = 'DEFAULT', SIGNING_DEVICE = 'SIGNING_DEVICE', } + +const TOAST_CONTAINER_TEST_ID = 'toast_container'; + const useToastMessage = () => { const Toast = useToast(); const activeToastsRef = useRef<{ [message: string]: any }>({}); @@ -54,7 +57,14 @@ const useToastMessage = () => { m: 0, }, render: () => ( - Toast.close(toastId)}> + Toast.close(toastId)} + > ), @@ -85,5 +95,4 @@ const useToastMessage = () => { return { showToast }; }; - export default useToastMessage; diff --git a/src/models/interfaces/HelpAi.ts b/src/models/interfaces/HelpAi.ts new file mode 100644 index 0000000000..d294b1b489 --- /dev/null +++ b/src/models/interfaces/HelpAi.ts @@ -0,0 +1,71 @@ +export type HelpChatMessage = { + role: 'user' | 'ai'; + text: string; + time?: string; +}; + +export type HelpChatMetadata = { + appVersion: string; + platform: 'ios' | 'android'; + device: string; + currentScreen: string; + walletType?: string; + signerType?: string; + locale?: string; + timezone?: string; +}; + +export type HelpDraft = { + kind: 'bug' | 'feature'; + title: string; + steps?: string[]; + expected?: string; + actual?: string; + problem?: string; + proposed?: string; +}; + +export type HelpEscalationCard = { + type: 'telegram' | 'advisor' | 'developer_email'; + title: string; + description: string; + ctaLabel: string; +}; + +export type HelpIconType = + | 'chat' + | 'clock' + | 'download' + | 'link' + | 'lightbulb' + | 'shield' + | 'key' + | 'wallet' + | 'alert' + | 'hardware' + | 'inheritance' + | 'vault' + | 'fee' + | 'address' + | 'node'; + +export type HelpChatResponse = { + reply: string; + iconType: HelpIconType; + intent: 'help' | 'bug' | 'feature'; + draft?: HelpDraft; + draftReadyForConfirmation: boolean; + sources?: Array<{ title: string; url: string }>; + escalationCard?: HelpEscalationCard; + conversationState?: { + messageCount: number; + dissatisfactionCount: number; + escalationStage: 'none' | 'telegram_suggested' | 'advisor_suggested' | 'email_suggested'; + }; +}; + +export type HelpIssueSubmitResponse = { + issueUrl: string; + issueNumber: number; + thankYouMessage: string; +}; \ No newline at end of file diff --git a/src/navigation/Navigator.tsx b/src/navigation/Navigator.tsx index 24d46fad48..2f27ed9086 100644 --- a/src/navigation/Navigator.tsx +++ b/src/navigation/Navigator.tsx @@ -126,6 +126,8 @@ import { AppStackParams } from './types'; import { registerSentryNavigationContainer } from 'src/services/sentry'; import KeyHistory from 'src/screens/Vault/KeyHistory'; import NodeSelection from 'src/screens/AppSettings/Node/NodeSelection'; +import HelpAiEntry from 'src/screens/HelpAi/HelpAiEntry'; +import HelpAiChat from 'src/screens/HelpAi/HelpAiChat'; import KeeperConcierge from 'src/screens/KeeperConcierge/KeeperConcierge'; import TechnicalSupport from 'src/screens/KeeperConcierge/TechnicalSupport'; import TicketDetails from 'src/screens/KeeperConcierge/TicketDetails'; @@ -174,6 +176,8 @@ import { SwapAllHistory } from 'src/screens/Home/components/buyBtc/Swap/SwapAllH import { TipBottomSheet } from 'src/components/Modal/TipBottomSheet'; import { SendTip } from 'src/screens/Send/SendTip'; import { ViewRecoveryKeyScreen } from 'src/screens/BackupWallet/ViewRecoveryKeyScreen'; +import RagChunkAdminScreen from 'src/screens/AppSettings/RagChunkAdminScreen'; +import AskKeeperInfoScreen from 'src/screens/HelpAi/AskKeeperInfoScreen'; function LoginStack() { const Stack = createNativeStackNavigator(); @@ -356,6 +360,8 @@ function AppStack() { + + @@ -381,6 +387,8 @@ function AppStack() { + + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 021dd59a55..d29b4d6f13 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -17,6 +17,7 @@ export type AppStackParams = { PassportConfigRecovery: undefined; AppSettings: undefined; AppVersionHistory: undefined; + RagChunkAdmin: undefined; TorSettings: undefined; ManageWallets: undefined; PreviewPDF: undefined; @@ -168,6 +169,17 @@ export type AppStackParams = { ResetInitialTimelock: undefined; ResetInheritanceKey: undefined; ResetEmergencyKey: undefined; + HelpAiEntry: undefined; + HelpAiChat: + | { + prefillText?: string; + conversationId?: string; + startFresh?: boolean; + screenName?: string; + tags?: string[]; + errorDetails?: string; + } + | undefined; KeeperConcierge: undefined; TechnicalSupport: { newTicketId: string; @@ -189,6 +201,7 @@ export type AppStackParams = { ImportedWalletSetup: undefined; SendTip: undefined; ViewRecoveryKeyScreen: undefined; + AskKeeperInfo: undefined; }; // Usage: diff --git a/src/screens/AppSettings/AppVersionHistoty.tsx b/src/screens/AppSettings/AppVersionHistoty.tsx index 8486180164..6e411f6029 100644 --- a/src/screens/AppSettings/AppVersionHistoty.tsx +++ b/src/screens/AppSettings/AppVersionHistoty.tsx @@ -1,10 +1,9 @@ -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import Text from 'src/components/KeeperText'; import { ScrollView, useColorMode } from '@gluestack-ui/themed-native-base'; -import { StyleSheet } from 'react-native'; +import { Pressable, StyleSheet } from 'react-native'; import VersionHistoryList from 'src/components/SettingComponent/VersionHistoryList'; import ScreenWrapper from 'src/components/ScreenWrapper'; -import KeeperHeader from 'src/components/KeeperHeader'; import { RealmSchema } from 'src/storage/realm/enum'; import { KeeperApp } from 'src/models/interfaces/KeeperApp'; import dbManager from 'src/storage/realm/dbManager'; @@ -12,11 +11,22 @@ import { LocalizationContext } from 'src/context/Localization/LocContext'; import { hp, wp } from 'src/constants/responsive'; import WalletHeader from 'src/components/WalletHeader'; -function AppVersionHistory() { +function AppVersionHistory({ navigation }: any) { const { colorMode } = useColorMode(); const { publicId }: KeeperApp = dbManager.getObjectByIndex(RealmSchema.KeeperApp); const { translations } = useContext(LocalizationContext); const { settings } = translations; + const [tapCount, setTapCount] = useState(0); + + const onAppIdPress = () => { + const next = tapCount + 1; + if (next < 7) { + setTapCount(next); + return; + } + setTapCount(0); + navigation.navigate('RagChunkAdmin'); + }; return ( @@ -27,7 +37,9 @@ function AppVersionHistory() { - {`App ID: ${publicId}`} + + {`App ID: ${publicId}`} + ); } diff --git a/src/screens/AppSettings/RagChunkAdminScreen.tsx b/src/screens/AppSettings/RagChunkAdminScreen.tsx new file mode 100644 index 0000000000..779fdda0d2 --- /dev/null +++ b/src/screens/AppSettings/RagChunkAdminScreen.tsx @@ -0,0 +1,273 @@ +import React, { useState, useEffect } from 'react'; +import { Alert, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; +import Text from 'src/components/KeeperText'; +import ScreenWrapper from 'src/components/ScreenWrapper'; +import WalletHeader from 'src/components/WalletHeader'; +import Relay from 'src/services/backend/Relay'; +import dbManager from 'src/storage/realm/dbManager'; +import { RealmSchema } from 'src/storage/realm/enum'; +import { hp, windowHeight, wp } from 'src/constants/responsive'; +import { useColorMode } from '@gluestack-ui/themed-native-base'; +import { useMemo } from 'react'; +import Colors from 'src/theme/Colors'; + +const RagChunkAdminScreen = () => { + const { colorMode } = useColorMode(); + const isDarkMode = colorMode === 'dark'; + const uiColors = useMemo( + () => ({ + separator: isDarkMode ? '#3a3a3a' : '#d8d8d8', + primaryText: isDarkMode ? Colors.bodyText : Colors.secondaryBlack, + secondaryText: isDarkMode ? Colors.darkGrey : Colors.secondaryDarkGrey, + buttonText: '#ffffff', + placeholderText: isDarkMode ? '#9b9b9b' : '#8a8a8a', + }), + [isDarkMode] + ); + + const { publicId }: KeeperApp = dbManager.getObjectByIndex(RealmSchema.KeeperApp); + const [isAllowed, setIsAllowed] = useState(false); + const [accessChecked, setAccessChecked] = useState(false); + const [checkingAccess, setCheckingAccess] = useState(false); + const [content, setContent] = useState(''); + const [title, setTitle] = useState(''); + const [url, setUrl] = useState(''); + const [ragTimestamp, setRagTimestamp] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [responseData, setResponseData] = useState(null); + + useEffect(() => { + const checkAccess = async () => { + setCheckingAccess(true); + setAccessChecked(false); + try { + const response = await Relay.ragChunkAccessCheck(publicId); + setIsAllowed(!!response?.allowed); + } catch { + setIsAllowed(false); + } finally { + setAccessChecked(true); + setCheckingAccess(false); + } + }; + checkAccess(); + }, [publicId]); + + const resetFormForAnotherEntry = () => { + setResponseData(null); + setContent(''); + setTitle(''); + setUrl(''); + setRagTimestamp(''); + }; + + const isValidDate = (value: string): boolean => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00Z`); + if (isNaN(date.getTime())) return false; + const [year, month, day] = value.split('-').map(Number); + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() + 1 === month && + date.getUTCDate() === day + ); + }; + + const submitChunk = async () => { + if (!content || content.trim().length < 10) { + Alert.alert('Error', 'Content must be at least 10 characters long'); + return; + } + if (ragTimestamp.trim().length && !isValidDate(ragTimestamp.trim())) { + Alert.alert('Error', 'Date must be in YYYY-MM-DD format (e.g. 2025-07-22)'); + return; + } + setSubmitting(true); + try { + const payload: { + publicId: string; + content: string; + title?: string; + url?: string; + ragTimestamp?: string; + } = { + publicId, + content: content.trim(), + }; + if (title.trim().length) payload.title = title.trim(); + if (url.trim().length) payload.url = url.trim(); + if (ragTimestamp.trim().length) payload.ragTimestamp = ragTimestamp.trim(); + const result = await Relay.addRagChunkFrontend(payload); + setResponseData(result); + } catch (error) { + Alert.alert('Error', error?.message || 'Request failed'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {!accessChecked || checkingAccess ? ( + Checking access... + ) : !isAllowed ? ( + Access not available + ) : responseData ? ( + + Ingestion result + + {JSON.stringify(responseData, null, 2)} + + + + Add another + + + + ) : ( + + + Title (optional) + + + + URL (optional) + + + + Date (YYYY-MM-DD, optional) + + + + Content (required) + + + + )} + {!responseData && isAllowed && ( + + + {submitting ? 'Submitting...' : 'Submit'} + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + formContainer: { + flex: 1, + }, + formContentContainer: { + paddingTop: hp(8), + paddingBottom: hp(90), + }, + info: { + textAlign: 'center', + marginBottom: hp(8), + }, + fieldLabel: { + fontSize: 12, + marginBottom: hp(3), + }, + input: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + marginBottom: hp(8), + }, + contentInput: { + minHeight: windowHeight * 0.35, + maxHeight: windowHeight * 0.35, + textAlignVertical: 'top', + }, + actionButton: { + borderRadius: 8, + paddingVertical: 10, + marginTop: hp(4), + }, + actionButtonDisabled: { + opacity: 0.6, + }, + actionButtonText: { + textAlign: 'center', + fontWeight: '600', + }, + submitButton: { + position: 'absolute', + bottom: hp(10), // Fixed at the bottom + left: wp(16), + right: wp(16), + }, + responseText: { + fontSize: 12, + marginTop: hp(4), + }, +}); + +export default RagChunkAdminScreen; diff --git a/src/screens/Help/HelpScreen.tsx b/src/screens/Help/HelpScreen.tsx new file mode 100644 index 0000000000..4c4414ee09 --- /dev/null +++ b/src/screens/Help/HelpScreen.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { Box, useColorMode } from '@gluestack-ui/themed-native-base'; +import { StyleSheet } from 'react-native'; +import Text from 'src/components/KeeperText'; +import CircleIconWrapper from 'src/components/CircleIconWrapper'; +import { hp, wp } from 'src/constants/responsive'; +// Use the previous concierge icon as the help icon +import ConciergeNeedHelpIcon from 'src/assets/images/conciergeNeedHelp.svg'; + +const HelpScreen = () => { + const { colorMode } = useColorMode(); + return ( + + + } + backgroundColor={`${colorMode}.separator`} + /> + + + Help & Support + + + Find answers to your questions, get support, and learn more about using Keeper. + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, + iconWrapper: { + marginBottom: hp(24), + }, + title: { + marginTop: hp(8), + marginBottom: hp(8), + textAlign: 'center', + }, + description: { + textAlign: 'center', + marginTop: hp(4), + maxWidth: wp(300), + }, +}); + +export default HelpScreen; diff --git a/src/screens/HelpAi/AskKeeperInfoScreen.tsx b/src/screens/HelpAi/AskKeeperInfoScreen.tsx new file mode 100644 index 0000000000..05ae1572ea --- /dev/null +++ b/src/screens/HelpAi/AskKeeperInfoScreen.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { ScrollView, View, StyleSheet } from 'react-native'; +import { ASK_KEEPER_INFO } from '../../constants/AskKeeperInfo'; +import Text from 'src/components/KeeperText'; +import ScreenWrapper from 'src/components/ScreenWrapper'; +import { useColorMode } from '@gluestack-ui/themed-native-base'; +import WalletHeader from 'src/components/WalletHeader'; +import Fonts from 'src/constants/Fonts'; + + +const AskKeeperInfoScreen = () => { + const { colorMode } = useColorMode(); + + return ( + + + + {ASK_KEEPER_INFO.sections.map((section, idx) => ( + + + {section.heading} + + + {section.body} + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + sectionCtr: { + marginBottom: 20, + paddingVertical: 8, + paddingHorizontal: 0, + }, + heading: { + fontSize: 17, + lineHeight: 23, + fontWeight: '600', + fontFamily: Fonts.LoraSemiBold, + marginBottom: 4, + }, + body: { + fontSize: 15, + lineHeight: 22, + fontFamily: Fonts.InterRegular, + marginTop: 2, + }, + divider: { + marginVertical: 10, + borderBottomWidth: 1, + }, +}); + +export default AskKeeperInfoScreen; diff --git a/src/screens/HelpAi/HelpAiChat.tsx b/src/screens/HelpAi/HelpAiChat.tsx new file mode 100644 index 0000000000..b66e5be9b0 --- /dev/null +++ b/src/screens/HelpAi/HelpAiChat.tsx @@ -0,0 +1,816 @@ +import { useColorMode } from '@gluestack-ui/themed-native-base'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + Linking, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import DeviceInfo from 'react-native-device-info'; +import Buttons from 'src/components/Buttons'; +import KeeperModal from 'src/components/KeeperModal'; +import { hp, wp } from 'src/constants/responsive'; +import useToastMessage from 'src/hooks/useToastMessage'; +import { + HelpChatMessage, + HelpChatMetadata, + HelpChatResponse, + HelpEscalationCard, +} from 'src/models/interfaces/HelpAi'; +import Relay from 'src/services/backend/Relay'; +import { useQuery } from '@realm/react'; +import { RealmSchema } from 'src/storage/realm/enum'; +import { getJSONFromRealmObject } from 'src/storage/realm/utils'; +import { KeeperApp } from 'src/models/interfaces/KeeperApp'; +import HelpAiDraftCard from './components/HelpAiDraftCard'; +import HelpAiShell from './components/HelpAiShell'; +import PaperPlaneLight from 'src/assets/images/paper-plane-light.svg'; +import PaperPlaneDark from 'src/assets/images/paper-plane-dark.svg'; +import Colors from 'src/theme/Colors'; +import { batch } from 'react-redux'; +import { useAppDispatch, useAppSelector } from 'src/store/hooks'; +import { + appendHelpAiMessage, + createHelpAiThread, + incrementHelpAiIssueCount, + setHelpAiChatMeta, + setHelpAiDraft, + setHelpAiDraftStatus, + setHelpAiEscalationStage, + setHelpAiRawMessages, + type HelpAiDraftStatus, + type HelpAiRenderMessage, +} from 'src/store/reducers/helpAi'; +import { GREETINGS } from 'src/constants/ChatAiGreetings'; +import { + HELP_AI_ESCALATION_ADVISOR_ROUTE, + HELP_AI_DISCLAIMER_STORAGE_KEY, + HELP_AI_ESCALATION_DEV_EMAIL, + HELP_AI_ESCALATION_DEV_EMAIL_BODY, + HELP_AI_ESCALATION_DEV_EMAIL_SUBJECT, + HELP_AI_ESCALATION_TELEGRAM_URL, +} from 'src/constants/helpAiEscalation'; +import { LocalizationContext } from 'src/context/Localization/LocContext'; +import { hasItem, setItem } from 'src/storage'; +import AskKeeperShield from 'src/assets/images/ask_keeper_shield.svg'; +import AskKeeperInfo from 'src/assets/images/ask_keeper_info.svg'; +import AskKeeperLock from 'src/assets/images/ask_keeper_lock.svg'; +import LockIcon from 'src/assets/images/lockLightGreen.svg'; +import Fonts from 'src/constants/Fonts'; +import { HELP_AI_ICON_MAP } from 'src/constants/helpAiIcons'; +import { sanitizeHelpAiReplyLinks, sanitizeHelpAiSources } from 'src/utils/helpAiLinkPolicy'; +import { detectSensitiveInput, detectSensitiveInDraft } from 'src/utils/helpAiSensitiveData'; + +// Enhanced error handler to distinguish between all backend rate limit codes +const getHelpAiFriendlyError = (error: any, scope: 'chat' | 'issue'): string => { + const rawMessage = error?.message || error?.error || ''; + + if (rawMessage.includes('HELP_AI_NEW_CHAT_LIMIT_REACHED')) { + return 'You have reached your daily new chat creation limit. Please try again after 24 hours.'; + } + if (rawMessage.includes('HELP_AI_DAILY_MESSAGE_LIMIT_REACHED')) { + return 'You have reached your daily message limit for this chat. Please try again after 24 hours.'; + } + if (rawMessage.includes('HELP_AI_ISSUE_LIMIT_REACHED')) { + return 'You have reached your daily issue submission limit. Please try again after 24 hours.'; + } + if (rawMessage.includes('HELP_AI_RATE_LIMIT_REACHED')) { + return 'You have reached a rate limit. Please try again after 24 hours.'; + } + if (rawMessage.includes('429')) { + return scope === 'chat' + ? 'Chat is temporarily rate-limited. Please try again shortly.' + : 'Issue submission is temporarily rate-limited. Please try again shortly.'; + } + return ( + rawMessage || + (scope === 'chat' + ? 'Unable to send message. Please retry.' + : 'Issue submission failed. Please retry.') + ); +}; + +const nowId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +const LOCK_ICON_SIZE = hp(15); + +const buildMetadata = async (): Promise => { + const appVersion = DeviceInfo.getVersion(); + const device = await DeviceInfo.getDeviceName(); + return { + appVersion, + platform: Platform.OS === 'ios' ? 'ios' : 'android', + device, + currentScreen: 'HelpAiChat', + locale: Intl.DateTimeFormat().resolvedOptions().locale, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }; +}; + +const HelpAiChat = ({ navigation, route }) => { + const dispatch = useAppDispatch(); + const { id: appId } = useQuery(RealmSchema.KeeperApp).map( + getJSONFromRealmObject + )[0] as unknown as KeeperApp; + const { colorMode } = useColorMode(); + const isDarkMode = colorMode === 'dark'; + const uiColors = useMemo( + () => ({ + surface: isDarkMode ? '#1f1f1f' : '#ffffff', + separator: isDarkMode ? '#3a3a3a' : '#d8d8d8', + primaryText: isDarkMode ? Colors.bodyText : Colors.secondaryBlack, + secondaryText: isDarkMode ? Colors.darkGrey : Colors.secondaryDarkGrey, + buttonText: '#ffffff', + link: isDarkMode ? Colors.mintGreen : Colors.primaryGreen, + error: isDarkMode ? Colors.CrimsonRed : Colors.redAlert, + userBubble: Colors.primaryGreen, + aiBubble: isDarkMode ? Colors.SecondaryBlack : 'white', + inputText: isDarkMode ? '#ffffff' : '#101010', + placeholderText: isDarkMode ? '#9b9b9b' : '#8a8a8a', + }), + [isDarkMode] + ); + const { translations } = useContext(LocalizationContext); + const { askAi } = translations; + const disclaimerPoints = [askAi.disclaimerPoint1, askAi.disclaimerPoint2, askAi.disclaimerPoint3]; + const { showToast } = useToastMessage(); + const listRef = useRef>(null); + const generatedConversationId = useMemo(() => `conv_${Date.now().toString(36)}`, []); + const conversationId = route?.params?.conversationId || generatedConversationId; + const initialPrompt = route?.params?.prefillText || ''; + const persistedThread = useAppSelector((state) => + state.helpAi.threads.find((thread) => thread.conversationId === conversationId) + ); + const greetings = useRef(GREETINGS[Math.floor(Math.random() * GREETINGS.length)]); + + const defaultIntro: HelpAiRenderMessage = { + id: 'help-ai-intro', + type: 'ai', + text: greetings.current, + iconType: 'chat', + }; + const messages = persistedThread?.messages?.length ? persistedThread.messages : [defaultIntro]; + const rawChatMessages = persistedThread?.rawMessages || []; + const draft = persistedThread?.draft || null; + const draftStatus: HelpAiDraftStatus = persistedThread?.draftStatus || 'pending_review'; + const lastEscalationStage = persistedThread?.lastEscalationStage || 'none'; + const chatMeta = persistedThread?.chatMeta || null; + const issueCount = persistedThread?.issueCount || 1; + + const [input, setInput] = useState(initialPrompt); + const [sending, setSending] = useState(false); + const [typing, setTyping] = useState(false); + const [lastFailedText, setLastFailedText] = useState(null); + const [showDisclaimerModal, setShowDisclaimerModal] = useState(false); + + useEffect(() => { + dispatch(createHelpAiThread({ conversationId })); + }, [conversationId, dispatch]); + + useEffect(() => { + if (!hasItem(HELP_AI_DISCLAIMER_STORAGE_KEY)) { + setShowDisclaimerModal(true); + } + }, []); + + const dismissDisclaimerModal = () => { + setItem(HELP_AI_DISCLAIMER_STORAGE_KEY, true); + setShowDisclaimerModal(false); + }; + + const scrollToBottom = () => { + requestAnimationFrame(() => listRef.current?.scrollToEnd({ animated: true })); + }; + + useEffect(() => { + if (!messages.length) return undefined; + // Small delay to let FlatList measure new content before scrolling + const t = setTimeout(scrollToBottom, 150); + return () => clearTimeout(t); + }, [messages.length]); + + const appendMessage = (message: HelpAiRenderMessage) => { + dispatch(appendHelpAiMessage({ conversationId, message })); + }; + + const handleEscalationCardAction = async (card: HelpEscalationCard) => { + try { + if (card.type === 'telegram') { + await Linking.openURL(HELP_AI_ESCALATION_TELEGRAM_URL); + } else if (card.type === 'advisor') { + navigation.navigate(HELP_AI_ESCALATION_ADVISOR_ROUTE); + } else if (card.type === 'developer_email') { + const subject = encodeURIComponent(HELP_AI_ESCALATION_DEV_EMAIL_SUBJECT); + const body = encodeURIComponent(HELP_AI_ESCALATION_DEV_EMAIL_BODY); + await Linking.openURL( + `mailto:${HELP_AI_ESCALATION_DEV_EMAIL}?subject=${subject}&body=${body}` + ); + } + } catch (error) { + showToast('Unable to open this action right now'); + } + }; + + const sendToChat = async (text: string) => { + if (!text.trim()) return; + if (!appId) { + showToast('Missing app id. Please restart the app and try again.'); + return; + } + const sensitiveResult = detectSensitiveInput(text.trim()); + if (sensitiveResult) { + showToast(sensitiveResult.message); + return; + } + + const outboundText = text.trim(); + setInput(''); + setSending(true); + setTyping(true); + appendMessage({ id: nowId(), type: 'user', text: outboundText }); + + try { + const metadata = chatMeta || (await buildMetadata()); + if (!chatMeta) { + dispatch(setHelpAiChatMeta({ conversationId, chatMeta: metadata })); + } + const nextHistory: HelpChatMessage[] = [ + ...rawChatMessages, + { role: 'user' as const, text: outboundText, time: new Date().toISOString() }, + ].slice(-20); + const response: HelpChatResponse = await Relay.helpChat({ + appId, + conversationId, + messages: nextHistory, + userText: outboundText, + metadata, + }); + + const sanitizedReply = sanitizeHelpAiReplyLinks(response.reply); + const sanitizedSources = sanitizeHelpAiSources(response.sources); + const aiMsg: HelpChatMessage = { + role: 'ai', + text: sanitizedReply, + time: new Date().toISOString(), + }; + const aiRenderMsg: HelpAiRenderMessage = { + id: nowId(), + type: 'ai', + text: sanitizedReply, + iconType: response.iconType, + sources: sanitizedSources, + }; + const escalationRenderMsg: HelpAiRenderMessage | null = + response.escalationCard && + response.conversationState?.escalationStage !== lastEscalationStage + ? { id: nowId(), type: 'escalation', card: response.escalationCard } + : null; + batch(() => { + dispatch( + setHelpAiRawMessages({ conversationId, messages: [...nextHistory, aiMsg].slice(-20) }) + ); + dispatch(appendHelpAiMessage({ conversationId, message: aiRenderMsg })); + if (response.draft && (response.intent === 'bug' || response.intent === 'feature')) { + dispatch(setHelpAiDraft({ conversationId, draft: response.draft })); + dispatch(setHelpAiDraftStatus({ conversationId, draftStatus: 'pending_review' })); + } + if (escalationRenderMsg) { + dispatch( + setHelpAiEscalationStage({ + conversationId, + escalationStage: response.conversationState.escalationStage, + }) + ); + dispatch(appendHelpAiMessage({ conversationId, message: escalationRenderMsg })); + } + }); + setLastFailedText(null); + } catch (error) { + showToast(getHelpAiFriendlyError(error, 'chat')); + setLastFailedText(outboundText); + } finally { + setSending(false); + setTyping(false); + } + }; + + const submitDraftIssue = async () => { + if (!draft || !chatMeta) return; + if (!appId) { + showToast('Missing app id. Please restart the app and try again.'); + return; + } + + const draftSensitiveResult = detectSensitiveInDraft(draft); + if (draftSensitiveResult) { + showToast(draftSensitiveResult.message); + return; + } + + try { + dispatch(setHelpAiDraftStatus({ conversationId, draftStatus: 'submitting' })); + const idempotencyKey = `issue-${conversationId}-${issueCount}`; + const response = await Relay.submitHelpIssue({ + appId, + conversationId, + kind: draft.kind, + confirm: true, + idempotencyKey, + draft, + metadata: { + appVersion: chatMeta.appVersion, + platform: chatMeta.platform, + device: chatMeta.device, + }, + }); + + dispatch(incrementHelpAiIssueCount({ conversationId })); + dispatch(setHelpAiDraftStatus({ conversationId, draftStatus: 'submitted' })); + appendMessage({ + id: nowId(), + type: 'issue', + issueUrl: response.issueUrl, + issueNumber: response.issueNumber, + text: response.thankYouMessage, + }); + appendMessage({ + id: nowId(), + type: 'ai', + text: response.thankYouMessage, + iconType: 'chat', + }); + } catch (error) { + dispatch(setHelpAiDraftStatus({ conversationId, draftStatus: 'failed_retryable' })); + showToast(getHelpAiFriendlyError(error, 'issue')); + } + }; + + const renderMessage = ({ item, index }: { item: HelpAiRenderMessage; index: number }) => { + if (item.type === 'escalation') { + return ( + + + {item.card.title} + + + {item.card.description} + + + handleEscalationCardAction(item.card)} + fullWidth + /> + + + ); + } + + if (item.type === 'issue') { + return ( + + + {`Issue #${item.issueNumber} created`} + + + {item.text} + + Linking.openURL(item.issueUrl)}> + + {item.issueUrl} + + + + ); + } + + if (item.type === 'system_error') { + return ( + + {item.text} + {item.retryText ? ( + sendToChat(item.retryText)}> + + Retry + + + ) : null} + + ); + } + + const isUser = item.type === 'user'; + const sources = item.type === 'ai' ? item.sources : undefined; + const iconType = item.type === 'ai' ? item.iconType : undefined; + const AvatarIcon = HELP_AI_ICON_MAP[iconType || 'chat'] || HELP_AI_ICON_MAP.chat; + + const bubble = ( + + + {item.text} + + {sources?.length > 0 && ( + + {sources.map((source, idx) => ( + Linking.openURL(source.url)} + > + + {source.title} + + + ))} + + )} + + ); + + if (!isUser) { + return ( + + + + + {bubble} + + ); + } + return bubble; + }; + + return ( + setShowDisclaimerModal(true)}> + + + item.id} + renderItem={({ item, index }) => ( + + {renderMessage({ item, index })} + + )} + contentContainerStyle={styles.messagesContainer} + keyboardShouldPersistTaps="handled" + ListFooterComponent={ + <> + {typing && ( + + + + Keeper is typing... + + + )} + {draft && draftStatus !== 'submitted' && ( + + dispatch( + setHelpAiDraftStatus({ + conversationId, + draftStatus: 'confirming_public_submission', + }) + ) + } + onConfirm={submitDraftIssue} + onCancel={() => + dispatch( + setHelpAiDraftStatus({ conversationId, draftStatus: 'pending_review' }) + ) + } + onRetry={submitDraftIssue} + /> + )} + + } + /> + + + + + + + + {askAi.neverShareSeedWarning} + + + + sendToChat(input)} + disabled={sending} + > + {isDarkMode ? ( + + ) : ( + + )} + + + + + {lastFailedText ? ( + sendToChat(lastFailedText)}> + + Retry last failed message + + + ) : null} + + ( + + + + + {disclaimerPoints[0]} + + + + + + {disclaimerPoints[1]} + + + + + + {disclaimerPoints[2]} + + + { + navigation.navigate('AskKeeperInfo'); + setShowDisclaimerModal(false); + }} + style={styles.learnMoreCtr} + > + + {askAi.disclaimerLearnMore} + + › + + + )} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: wp(16), + paddingBottom: hp(12), + }, + messagesContainer: { + paddingVertical: hp(14), + gap: hp(5), + }, + scrollArea: { + flex: 1, + minHeight: 0, + }, + messageWrapper: { + overflow: 'hidden', + }, + inputBar: { + paddingTop: hp(5), + }, + input: { + flex: 1, + maxHeight: hp(100), + minHeight: hp(42), + fontSize: 13, + lineHeight: 18, + borderWidth: 1, + borderColor: Colors.greyBorder, + borderRadius: 20, + paddingHorizontal: wp(12), + ...Platform.select({ + ios: { + paddingVertical: hp(12), + }, + android: { + textAlignVertical: 'center', + paddingVertical: hp(8), + }, + }), + }, + sendBtn: { + borderRadius: 100, + height: hp(38), + width: hp(38), + alignItems: 'center', + justifyContent: 'center', + }, + typingCtr: { + flexDirection: 'row', + gap: wp(8), + alignItems: 'center', + marginBottom: hp(8), + }, + card: { + borderWidth: 1, + borderRadius: 10, + padding: wp(12), + marginBottom: hp(8), + overflow: 'hidden', + }, + cardCtaCtr: { + marginTop: hp(8), + }, + bubble: { + maxWidth: '86%', + borderRadius: 16, + paddingHorizontal: wp(12), + paddingVertical: hp(10), + marginBottom: hp(8), + borderWidth: 1, + overflow: 'hidden', + }, + userBubble: { + alignSelf: 'flex-end', + }, + aiBubble: { + alignSelf: 'flex-start', + }, + sourcesCtr: { + marginTop: hp(6), + }, + linkText: { + marginTop: hp(6), + textDecorationLine: 'underline', + }, + systemErrorCtr: { + alignSelf: 'center', + marginBottom: hp(8), + alignItems: 'center', + gap: hp(3), + }, + retryBar: { + alignSelf: 'center', + marginTop: hp(8), + }, + textSmall: { + fontSize: 12, + lineHeight: 17, + }, + textMedium: { + fontSize: 14, + lineHeight: 20, + fontWeight: '500', + }, + textMediumWeight: { + fontWeight: '500', + }, + aiBubbleRow: { + flexDirection: 'row', + alignItems: 'flex-end', + gap: wp(6), + }, + aiAvatar: { + borderRadius: wp(50), + backgroundColor: '#c6ddd1', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + marginBottom: hp(8), + padding: 8, + }, + disclaimerText: { + fontSize: 14, + lineHeight: 18, + flex: 1, + }, + disclaimerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: wp(12), + marginBottom: hp(18), + }, + learnMoreCtr: { + borderTopWidth: 1, + borderTopColor: Colors.greyBorder, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: wp(6), + paddingTop: hp(12), + marginTop: hp(2), + marginBottom: hp(8), + }, + learnMoreText: { + fontSize: 13, + lineHeight: 18, + fontWeight: '500', + }, + learnMoreArrow: { + fontSize: 24, + lineHeight: 24, + }, + warningCtr: { + flexDirection: 'row', + alignItems: 'center', + gap: wp(6), + alignSelf: 'center', + marginBottom: hp(5), + }, + warningIconBox: { + width: LOCK_ICON_SIZE, + height: LOCK_ICON_SIZE, + overflow: 'hidden', + }, + warningText: { + paddingTop: hp(3), + color: Colors.DarkSlateGray, + fontFamily: Fonts.InterRegular, + marginBottom: 4, + fontSize: 14, + }, + inputWrapper: { + flexDirection: 'row', + gap: wp(8), + alignItems: 'flex-end', + flexShrink: 0, + }, +}); + +export default HelpAiChat; diff --git a/src/screens/HelpAi/HelpAiEntry.tsx b/src/screens/HelpAi/HelpAiEntry.tsx new file mode 100644 index 0000000000..ce9c6b5e4d --- /dev/null +++ b/src/screens/HelpAi/HelpAiEntry.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import HelpAiEntryCard from './components/HelpAiEntryCard'; +import { CommonActions, useNavigation } from '@react-navigation/core'; +import { useAppSelector } from 'src/store/hooks'; +import HelpAiChatHistoryList from './components/HelpAiChatHistoryList'; + +const HelpAiEntry = () => { + const navigation = useNavigation(); + const threads = useAppSelector((state) => state.helpAi.threads); + const chatThreads = threads.filter((thread) => thread.messages.length > 0); + + const hasChats = chatThreads.length > 0; + + if (hasChats) { + return ( + + navigation.dispatch(CommonActions.navigate('HelpAiChat', { conversationId })) + } + onStartNewChat={() => + navigation.dispatch(CommonActions.navigate('HelpAiChat', { startFresh: true })) + } + /> + ); + } + + return ( + navigation.dispatch(CommonActions.navigate('HelpAiChat', { startFresh: true }))} + onPromptPress={(prefillText) => + navigation.dispatch(CommonActions.navigate('HelpAiChat', { prefillText, startFresh: true })) + } + /> + ); +}; + +export default HelpAiEntry; diff --git a/src/screens/HelpAi/components/HelpAiChatHistoryList.tsx b/src/screens/HelpAi/components/HelpAiChatHistoryList.tsx new file mode 100644 index 0000000000..3ecc34fe77 --- /dev/null +++ b/src/screens/HelpAi/components/HelpAiChatHistoryList.tsx @@ -0,0 +1,186 @@ +import { useColorMode } from '@gluestack-ui/themed-native-base'; +import { Text, View } from 'react-native'; +import React from 'react'; +import { Pressable, ScrollView, StyleSheet } from 'react-native'; +import Fonts from 'src/constants/Fonts'; +import { hp, wp } from 'src/constants/responsive'; +import { HELP_AI_ICON_MAP, getThreadIconType } from 'src/constants/helpAiIcons'; +import { HelpAiThread } from 'src/store/reducers/helpAi'; +import Colors from 'src/theme/Colors'; +import PencilWhite from 'src/assets/images/edit_white.svg'; +import Fab from 'src/components/Fab'; + +type HelpAiChatHistoryListProps = { + threads: HelpAiThread[]; + onOpenChat: (conversationId: string) => void; + onStartNewChat: () => void; +}; + +const formatRelativeTime = (isoDate: string) => { + const time = new Date(isoDate).getTime(); + if (Number.isNaN(time)) return ''; + + const diffMs = Date.now() - time; + const minuteMs = 60 * 1000; + const hourMs = 60 * minuteMs; + const dayMs = 24 * hourMs; + + if (diffMs < minuteMs) return 'Just now'; + if (diffMs < hourMs) return `${Math.floor(diffMs / minuteMs)}m ago`; + if (diffMs < dayMs) return `${Math.floor(diffMs / hourMs)}h ago`; + if (diffMs < dayMs * 7) return `${Math.floor(diffMs / dayMs)}d ago`; + + return new Date(isoDate).toLocaleDateString(); +}; + +const HelpAiChatHistoryList = ({ + threads, + onOpenChat, + onStartNewChat, +}: HelpAiChatHistoryListProps) => { + const { colorMode } = useColorMode(); + + return ( + + + {threads.map((thread, idx) => { + const iconType = getThreadIconType(thread.messages); + const ThreadIcon = HELP_AI_ICON_MAP[iconType] || HELP_AI_ICON_MAP.chat; + + return ( + onOpenChat(thread.conversationId)} + style={({ pressed }) => [styles.itemPressable, { opacity: pressed ? 0.75 : 1 }]} + > + + + + + + {/* Icon + header row */} + + + + {thread.title || 'New chat'} + + + + {formatRelativeTime(thread.updatedAt)} + + + + {/* Preview */} + + {thread.lastMessage || 'No messages yet'} + + + + {idx < threads.length - 1 && } + + ); + })} + + } + onPress={onStartNewChat} + containerStyle={{ right: wp(22) }} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: wp(12), + width: '100%', + }, + title: {}, + listContent: { + paddingBottom: hp(20), + }, + itemPressable: { + width: '100%', + }, + itemCard: { + overflow: 'hidden', + flexDirection: 'row', + alignItems: 'center', + }, + cardBody: { + flex: 1, + gap: hp(0), + }, + itemHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: wp(8), + }, + iconAndTitle: { + flexDirection: 'row', + alignItems: 'center', + gap: wp(8), + flex: 1, + }, + iconWrap: { + marginRight: wp(8), + borderRadius: wp(100), + padding: 10, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#c6ddd1', + }, + threadTitle: { + flex: 1, + maxWidth: '70%', + fontSize: 17, + fontWeight: '500', + }, + metaText: { + fontSize: 11, + fontFamily: Fonts.InterRegular, + }, + previewText: { + fontSize: 13, + fontFamily: Fonts.InterRegular, + lineHeight: hp(18), + maxWidth: '90%', + }, + ctaCtr: { + paddingBottom: hp(12), + paddingTop: hp(6), + }, + spacer: { + height: hp(1), + width: '95%', + marginVertical: hp(10), + backgroundColor: Colors.greyBorder, + opacity: 0.5, + alignSelf: 'center', + }, +}); + +export default HelpAiChatHistoryList; diff --git a/src/screens/HelpAi/components/HelpAiDraftCard.tsx b/src/screens/HelpAi/components/HelpAiDraftCard.tsx new file mode 100644 index 0000000000..5e3259eda4 --- /dev/null +++ b/src/screens/HelpAi/components/HelpAiDraftCard.tsx @@ -0,0 +1,166 @@ +import { useColorMode } from '@gluestack-ui/themed-native-base'; +import React, { useEffect, useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, useColorScheme, View } from 'react-native'; +import Buttons from 'src/components/Buttons'; +import { hp, wp } from 'src/constants/responsive'; +import { HelpDraft } from 'src/models/interfaces/HelpAi'; + +type DraftStatus = + | 'pending_review' + | 'confirming_public_submission' + | 'submitting' + | 'submitted' + | 'failed_retryable'; + +type HelpAiDraftCardProps = { + draft: HelpDraft; + draftStatus: DraftStatus; + onReview: () => void; + onConfirm: () => void; + onCancel: () => void; + onRetry: () => void; +}; + +const HelpAiDraftCard = ({ + draft, + draftStatus, + onReview, + onConfirm, + onCancel, + onRetry, +}: HelpAiDraftCardProps) => { + const { colorMode } = useColorMode(); + const isDarkMode = colorMode === 'dark'; + // Workaround: react-redux 7.x does not reliably trigger child re-renders + // with React 19 when parent passes new props via useAppSelector. + const [, forceUpdate] = useState(0); + useEffect(() => { + const t = setTimeout(() => forceUpdate((n) => n + 1), 100); + return () => clearTimeout(t); + }, [draftStatus]); + const uiColors = React.useMemo( + () => ({ + surface: isDarkMode ? '#1f1f1f' : '#ffffff', + separator: isDarkMode ? '#3a3a3a' : '#d8d8d8', + primaryText: isDarkMode ? '#e7e7e7' : '#272421', + secondaryText: isDarkMode ? '#a5a5a5' : '#878787', + pantoneGreen: '#2f4f4f', + }), + [isDarkMode] + ); + + const fields: Array<{ label: string; value?: string | string[] }> = [ + { label: 'Title', value: draft.title }, + ...(draft.steps ? [{ label: 'Steps', value: draft.steps }] : []), + ...(draft.expected ? [{ label: 'Expected', value: draft.expected }] : []), + ...(draft.actual ? [{ label: 'Actual', value: draft.actual }] : []), + ...(draft.problem ? [{ label: 'Problem', value: draft.problem }] : []), + ...(draft.proposed ? [{ label: 'Proposed', value: draft.proposed }] : []), + ]; + + return ( + + + {draft.kind === 'bug' ? 'Bug Draft' : 'Feature Draft'} + + + {fields.map((field) => { + const isArray = Array.isArray(field.value); + return ( + + + {field.label} + + {isArray ? ( + + {(field.value as string[]).map((line, idx) => ( + + {`${idx + 1}. ${line}`} + + ))} + + ) : ( + + {(field.value as string) ?? ''} + + )} + + ); + })} + + {draftStatus === 'pending_review' && ( + + + + )} + + {draftStatus === 'confirming_public_submission' && ( + + + This will create a public GitHub issue. Confirm only if you are comfortable sharing this + information publicly. + + + + + )} + + {draftStatus === 'submitting' && ( + + + Submitting issue... + + )} + + {draftStatus === 'failed_retryable' && ( + + + + + )} + + {draftStatus === 'submitted' && ( + + + Submitted successfully. + + + )} + + ); +}; + +const styles = StyleSheet.create({ + gap10: { + gap: hp(10), + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: wp(8), + }, +}); + +export default HelpAiDraftCard; diff --git a/src/screens/HelpAi/components/HelpAiEntryCard.tsx b/src/screens/HelpAi/components/HelpAiEntryCard.tsx new file mode 100644 index 0000000000..ef1cabf9b2 --- /dev/null +++ b/src/screens/HelpAi/components/HelpAiEntryCard.tsx @@ -0,0 +1,166 @@ +import React, { useContext } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { hp, wp } from 'src/constants/responsive'; +import { LocalizationContext } from 'src/context/Localization/LocContext'; +import Fonts from 'src/constants/Fonts'; +import Colors from 'src/theme/Colors'; +import LockIcon from 'src/assets/images/lockLightGreen.svg'; +import PencilWhite from 'src/assets/images/edit_white.svg'; +import ChatBubble from 'src/assets/images/chatBubble.svg'; +import Fab from 'src/components/Fab'; + +type HelpAiEntryCardProps = { + onStartChat: () => void; + onPromptPress: (value: string) => void; +}; + +const suggestions = [ + 'Why is my transaction pending?', + 'How do I backup my wallet?', + "Something isn't working", + 'I have an idea for Keeper', +]; + +const LOCK_ICON_SIZE = hp(15); + +const HelpAiEntryCard = ({ onStartChat, onPromptPress }: HelpAiEntryCardProps) => { + const { translations } = useContext(LocalizationContext); + const { askAi } = translations; + + return ( + + + + + {askAi.noConversationsYet} + {askAi.subtitle} + + + + Try asking about + + + + + {suggestions.map((suggestion, idx) => ( + + onPromptPress(suggestion)}> + {suggestion} + + + ))} + + + + + + {askAi.neverShareSeedWarning} + + + } + onPress={onStartChat} + containerStyle={{ right: wp(-10) }} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: wp(24), + }, + title: { + marginTop: hp(16), + fontSize: 24, + fontFamily: Fonts.LoraSemiBold, + color: '#213b39', + }, + subtitle: { + marginTop: hp(8), + fontSize: 14, + fontFamily: Fonts.InterRegular, + textAlign: 'center', + color: Colors.DarkSlateGray, + }, + buttonCtr: { + width: wp(280), + marginTop: hp(20), + }, + suggestionsCtr: { + marginTop: hp(16), + gap: hp(8), + alignItems: 'center', + }, + suggestionPill: { + paddingHorizontal: wp(14), + paddingVertical: hp(4), + width: '90%', + paddingBottom: 15, + borderStyle: 'dashed', + alignItems: 'center', + borderBottomColor: Colors.greyBorder, + }, + suggestionText: { + fontSize: 15, + fontFamily: Fonts.InterRegular, + color: Colors.DarkSlateGray, + }, + warningText: { + marginBottom: hp(20), + textAlign: 'center', + color: Colors.DarkSlateGray, + fontFamily: Fonts.InterRegular, + fontSize: 14, + }, + suggestionCtr: { + flex: 1, + width: '100%', + justifyContent: 'space-between', + }, + warningCtr: { + flexDirection: 'row', + + gap: wp(8), + }, + warningIconBox: { + width: LOCK_ICON_SIZE, + height: LOCK_ICON_SIZE, + overflow: 'hidden', + }, + chatBubbleBox: { + width: wp(64), + height: hp(64), + marginVertical: hp(20), + overflow: 'hidden', + }, + hrCtr: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-evenly', + gap: wp(8), + paddingTop: hp(30), + paddingBottom: hp(5), + }, + hr: { + width: '20%', + height: 1, + backgroundColor: Colors.greyBorder, + }, + tryText: { + fontSize: 13, + fontFamily: Fonts.InterRegular, + color: Colors.DarkSlateGray, + }, +}); + +export default HelpAiEntryCard; diff --git a/src/screens/HelpAi/components/HelpAiShell.tsx b/src/screens/HelpAi/components/HelpAiShell.tsx new file mode 100644 index 0000000000..cd0f6ad369 --- /dev/null +++ b/src/screens/HelpAi/components/HelpAiShell.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useColorMode } from '@gluestack-ui/themed-native-base'; +import { KeyboardAvoidingView, Platform, View } from 'react-native'; +import ScreenWrapper from 'src/components/ScreenWrapper'; +import WalletHeader from 'src/components/WalletHeader'; + +type HelpAiShellProps = { + title: string; + children: React.ReactNode; + onBack?: () => void; + onInfoPress?: () => void; +}; + +const HelpAiShell = ({ title, children, onBack, onInfoPress }: HelpAiShellProps) => { + const { colorMode } = useColorMode(); + + return ( + + + + + + {children} + + + ); +}; + +export default HelpAiShell; diff --git a/src/screens/Home/HomeScreen.tsx b/src/screens/Home/HomeScreen.tsx index 284fdcdf13..d6846a9e42 100644 --- a/src/screens/Home/HomeScreen.tsx +++ b/src/screens/Home/HomeScreen.tsx @@ -3,6 +3,7 @@ import { Box, useColorMode } from '@gluestack-ui/themed-native-base'; import React, { useContext, useEffect, useRef, useState } from 'react'; import useWallets from 'src/hooks/useWallets'; import { useAppSelector } from 'src/store/hooks'; +import { NetworkType } from 'src/services/wallets/enums'; import useToastMessage from 'src/hooks/useToastMessage'; import { useDispatch } from 'react-redux'; import ToastErrorIcon from 'src/assets/images/toast_error.svg'; @@ -22,7 +23,6 @@ import TickIcon from 'src/assets/images/icon_tick.svg'; import ThemedSvg from 'src/components/ThemedSvg.tsx/ThemedSvg'; import ThemedColor from 'src/components/ThemedColor/ThemedColor'; import BuyBtc from './components/buyBtc/BuyBtc'; -import ConciergeComponent from './components/ConciergeComponent'; import KeeperModal from 'src/components/KeeperModal'; import Text from 'src/components/KeeperText'; import { useQuery } from '@realm/react'; @@ -30,6 +30,7 @@ import { RealmSchema } from 'src/storage/realm/enum'; import dbManager from 'src/storage/realm/dbManager'; import { setRecoveryKeyStatus } from 'src/store/reducers/account'; import RecoveryKeyIcon from 'src/assets/images/recover_white.svg'; +import HelpAiEntry from '../HelpAi/HelpAiEntry'; function NewHomeScreen({ route }) { const { colorMode } = useColorMode(); @@ -44,12 +45,13 @@ function NewHomeScreen({ route }) { useAppSelector((state) => state.bhr); const { showToast } = useToastMessage(); const { translations } = useContext(LocalizationContext); - const { home: homeTranslation, wallet: walletText, buyBTC: buyBTCText, common } = translations; + const { home: homeTranslation, wallet: walletText, buyBTC: buyBTCText, askAi } = translations; const [selectedOption, setSelectedOption] = useState( selectedOptionFromRoute || walletText.homeWallets ); const backupHistory = useQuery(RealmSchema.BackupHistory); const { recoveryKeyStatusByAppId } = useAppSelector((state) => state.account); + const { bitcoinNetworkType } = useAppSelector((state) => state.settings); const { id } = dbManager.getObjectByIndex(RealmSchema.KeeperApp) as any; // 'idle' | 'education' | 'skipWarning' @@ -142,13 +144,9 @@ function NewHomeScreen({ route }) { /> ), }; - case walletText.concierge: + case askAi.ask: return { - content: ( - - - - ), + content: , icon: ( - + {recoveryKeyStatus === 'skipped' && ( { const { colorMode } = useColorMode(); @@ -96,26 +99,37 @@ const ManageKeys = ({ addedSigner }) => { }, []); return ( - - - - - ( - - )} - /> + <> + + } + onPress={handleModalOpen} + containerStyle={{ right: wp(22.5) }} + /> + + + + ( + + )} + /> - - {inProgress && } - + + {inProgress && } + + ); }; @@ -124,6 +138,7 @@ export default ManageKeys; const styles = StyleSheet.create({ containerWrapper: { paddingHorizontal: '4.5%', + flex: 1, }, contentContainer: { flexDirection: 'row', diff --git a/src/screens/Home/components/Keys/SignerList.tsx b/src/screens/Home/components/Keys/SignerList.tsx index e1d5e3d814..ec5b6191d8 100644 --- a/src/screens/Home/components/Keys/SignerList.tsx +++ b/src/screens/Home/components/Keys/SignerList.tsx @@ -1,7 +1,7 @@ import { CommonActions } from '@react-navigation/native'; import { Box, useColorMode } from '@gluestack-ui/themed-native-base'; -import React, { useContext, useState } from 'react'; -import { ScrollView, StyleSheet, ViewStyle } from 'react-native'; +import React, { useState } from 'react'; +import { ScrollView, StyleSheet } from 'react-native'; import SignerCard from 'src/screens/AddSigner/SignerCard'; import { getSignerDescription, getSignerNameFromType } from 'src/hardware'; import { useIndicatorHook } from 'src/hooks/useIndicatorHook'; @@ -11,29 +11,21 @@ import { SDIcons } from 'src/screens/Vault/SigningDeviceIcons'; import { SignerType } from 'src/services/wallets/enums'; import { getKeyUID } from 'src/utils/utilities'; import { windowWidth, wp } from 'src/constants/responsive'; -import DashedCta from 'src/components/DashedCta'; -import Plus from 'src/assets/images/add-plus-white.svg'; -import { LocalizationContext } from 'src/context/Localization/LocContext'; import HardwareModalMap, { InteracationMode } from 'src/screens/Vault/HardwareModalMap'; import { KeeperApp } from 'src/models/interfaces/KeeperApp'; import { useQuery } from '@realm/react'; import { RealmSchema } from 'src/storage/realm/enum'; import { getJSONFromRealmObject } from 'src/storage/realm/utils'; -import ThemedColor from 'src/components/ThemedColor/ThemedColor'; -const SignerList = ({ navigation, handleModalOpen }) => { +const SignerList = ({ navigation }) => { const { signers } = useSigners('', false); const { colorMode } = useColorMode(); - const { translations } = useContext(LocalizationContext); - const { signer: signerText } = translations; const [showSSModal, setShowSSModal] = useState(false); const list = signers.filter((signer) => !signer.hidden); const { typeBasedIndicator } = useIndicatorHook({ types: [uaiType.SIGNING_DEVICES_HEALTH_CHECK, uaiType.RECOVERY_PHRASE_HEALTH_CHECK], }); - const HexagonIcon = ThemedColor({ name: 'HexagonIcon' }); - const dashed_CTA_background = ThemedColor({ name: 'dashed_CTA_background' }); const handleCardSelect = (signer) => { navigation.dispatch( @@ -48,17 +40,6 @@ const SignerList = ({ navigation, handleModalOpen }) => { getJSONFromRealmObject )[0]; - const customStyle: ViewStyle = { - width: windowWidth * 0.42, - height: wp(135), - borderRadius: 10, - borderWidth: 2, - borderStyle: 'dashed', - alignItems: 'center', - justifyContent: 'center', - margin: 3, - }; - return ( @@ -94,17 +75,6 @@ const SignerList = ({ navigation, handleModalOpen }) => { /> ); })} - } - iconWidth={33} - iconHeight={30} - customStyle={customStyle} - /> { const { colorMode } = useColorMode(); @@ -239,16 +241,9 @@ const HomeWallet = () => { return ( - setPickWalletType(true)} - icon={} - iconWidth={22} - iconHeight={20} - cardStyles={styles.DashedCtaStyle} + } + onPress={() => setPickWalletType(true)} /> state.settings); const currentCurrency = useAppSelector((state) => state.settings.currencyKind); + const { bitcoinNetworkType } = useAppSelector((state) => state.settings); const exchangeRates = useExchangeRates(); const currencyCode = useCurrencyCode(); @@ -350,7 +351,11 @@ function ReceiveScreen({ route }: { route }) { - } /> + } + /> => { + try { + const res = await RestClient.post(`${RELAY}chat`, payload); + return res.data as HelpChatResponse; + } catch (err: any) { + console.log('🚀 ~ Relay ~ helpChat ~ err:', err); + if (err.response) { + if (err.response.status === 429) { + const backendError = err.response.data?.error || err.response.data?.err; + if (backendError) { + throw new Error(backendError); + } + throw new Error('HELP_AI_CHAT_RATE_LIMIT_REACHED'); // Fallback if no specific error is provided + } + throw new Error(err.response.data?.error || err.response.data?.err || 'Unknown error'); + } + if (err.code) { + throw new Error(err.code); + } + throw new Error('An unexpected error occurred'); + } + }; + + public static submitHelpIssue = async (payload: { + appId: string; + conversationId: string; + kind: 'bug' | 'feature'; + confirm: true; + idempotencyKey: string; + draft: HelpDraft; + metadata: Pick; + }): Promise => { + try { + const res = await RestClient.post(`${RELAY}submitHelpIssue`, payload); + return res.data as HelpIssueSubmitResponse; + } catch (err: any) { + console.log('🚀 ~ Relay ~ submitHelpIssue ~ err:', err); + if (err.response) { + if (err.response.status === 429) { + throw new Error('HELP_AI_ISSUE_RATE_LIMIT_REACHED'); + } + throw new Error(err.response.data?.error || err.response.data?.err || 'Unknown error'); + } + if (err.code) { + throw new Error(err.code); + } + throw new Error('An unexpected error occurred'); + } + }; + + public static ragChunkAccessCheck = async ( + publicId: string + ): Promise<{ + allowed: boolean; + message?: string; + }> => { + try { + const res = await RestClient.get( + `${RELAY}ragChunkAccessCheck?publicId=${encodeURIComponent(publicId)}` + ); + return res.data as { allowed: boolean; message?: string }; + } catch (err) { + if (err?.response?.data) { + return err.response.data; + } + return { allowed: false, message: 'Access not available' }; + } + }; + + public static addRagChunkFrontend = async (payload: { + publicId: string; + content: string; + title?: string; + url?: string; + ragTimestamp?: string; + }): Promise => { + try { + const res = await RestClient.post(`${RELAY}addRagChunkFrontend`, payload); + return res.data; + } catch (err) { + if (err?.response?.data?.err) throw new Error(err.response.data.err); + if (err?.message) throw new Error(err.message); + throw new Error('An unexpected error occurred'); + } + }; } + diff --git a/src/store/reducers/helpAi.ts b/src/store/reducers/helpAi.ts new file mode 100644 index 0000000000..9bce34cb86 --- /dev/null +++ b/src/store/reducers/helpAi.ts @@ -0,0 +1,184 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { persistReducer } from 'redux-persist'; +import { + HelpChatMessage, + HelpIconType, + HelpChatMetadata, + HelpDraft, + HelpEscalationCard, +} from 'src/models/interfaces/HelpAi'; +import { reduxStorage } from 'src/storage'; + +export type HelpAiDraftStatus = + | 'pending_review' + | 'confirming_public_submission' + | 'submitting' + | 'submitted' + | 'failed_retryable'; + +export type HelpAiRenderMessage = + | { id: string; type: 'user'; text: string } + | { + id: string; + type: 'ai'; + text: string; + iconType?: HelpIconType; + sources?: Array<{ title: string; url: string }>; + } + | { id: string; type: 'escalation'; card: HelpEscalationCard } + | { id: string; type: 'issue'; issueUrl: string; issueNumber: number; text: string } + | { id: string; type: 'system_error'; text: string; retryText?: string }; + +export type HelpAiThread = { + conversationId: string; + title: string; + lastMessage: string; + updatedAt: string; + messages: HelpAiRenderMessage[]; + rawMessages: HelpChatMessage[]; + draft: HelpDraft | null; + draftStatus: HelpAiDraftStatus; + lastEscalationStage: string; + chatMeta: HelpChatMetadata | null; + issueCount: number; +}; + +type HelpAiState = { + threads: HelpAiThread[]; + activeConversationId: string | null; +}; + +const DEFAULT_DRAFT_STATUS: HelpAiDraftStatus = 'pending_review'; + +const initialState: HelpAiState = { + threads: [], + activeConversationId: null, +}; + +const getMessagePreview = (message: HelpAiRenderMessage): string => { + if (message.type === 'escalation') return message.card.title; + if (message.type === 'issue') return message.text; + return message.text; +}; + +const makeThread = (conversationId: string): HelpAiThread => ({ + conversationId, + title: 'New chat', + lastMessage: '', + updatedAt: new Date().toISOString(), + messages: [], + rawMessages: [], + draft: null, + draftStatus: DEFAULT_DRAFT_STATUS, + lastEscalationStage: 'none', + chatMeta: null, + issueCount: 1, +}); + +const findOrCreateThread = (state: HelpAiState, conversationId: string) => { + let thread = state.threads.find((item) => item.conversationId === conversationId); + if (!thread) { + thread = makeThread(conversationId); + state.threads.unshift(thread); + } + return thread; +}; + +const helpAiSlice = createSlice({ + name: 'helpAi', + initialState, + reducers: { + createHelpAiThread: (state, action: PayloadAction<{ conversationId: string }>) => { + findOrCreateThread(state, action.payload.conversationId); + state.activeConversationId = action.payload.conversationId; + }, + setHelpAiActiveConversation: (state, action: PayloadAction) => { + state.activeConversationId = action.payload; + }, + appendHelpAiMessage: ( + state, + action: PayloadAction<{ conversationId: string; message: HelpAiRenderMessage }> + ) => { + const thread = findOrCreateThread(state, action.payload.conversationId); + thread.messages.push(action.payload.message); + thread.lastMessage = getMessagePreview(action.payload.message); + thread.updatedAt = new Date().toISOString(); + + if ( + thread.title === 'New chat' && + action.payload.message.type === 'user' && + action.payload.message.text.trim() + ) { + thread.title = action.payload.message.text.trim().slice(0, 60); + } + + state.threads = state.threads + .slice() + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + }, + setHelpAiRawMessages: ( + state, + action: PayloadAction<{ conversationId: string; messages: HelpChatMessage[] }> + ) => { + const thread = findOrCreateThread(state, action.payload.conversationId); + thread.rawMessages = action.payload.messages; + thread.updatedAt = new Date().toISOString(); + }, + setHelpAiDraft: ( + state, + action: PayloadAction<{ conversationId: string; draft: HelpDraft | null }> + ) => { + const thread = findOrCreateThread(state, action.payload.conversationId); + thread.draft = action.payload.draft; + thread.updatedAt = new Date().toISOString(); + }, + setHelpAiDraftStatus: ( + state, + action: PayloadAction<{ conversationId: string; draftStatus: HelpAiDraftStatus }> + ) => { + const thread = findOrCreateThread(state, action.payload.conversationId); + thread.draftStatus = action.payload.draftStatus; + thread.updatedAt = new Date().toISOString(); + }, + setHelpAiEscalationStage: ( + state, + action: PayloadAction<{ conversationId: string; escalationStage: string }> + ) => { + const thread = findOrCreateThread(state, action.payload.conversationId); + thread.lastEscalationStage = action.payload.escalationStage; + thread.updatedAt = new Date().toISOString(); + }, + setHelpAiChatMeta: ( + state, + action: PayloadAction<{ conversationId: string; chatMeta: HelpChatMetadata | null }> + ) => { + const thread = findOrCreateThread(state, action.payload.conversationId); + thread.chatMeta = action.payload.chatMeta; + }, + incrementHelpAiIssueCount: (state, action: PayloadAction<{ conversationId: string }>) => { + const thread = findOrCreateThread(state, action.payload.conversationId); + thread.issueCount += 1; + thread.updatedAt = new Date().toISOString(); + }, + }, +}); + +export const { + createHelpAiThread, + setHelpAiActiveConversation, + appendHelpAiMessage, + setHelpAiRawMessages, + setHelpAiDraft, + setHelpAiDraftStatus, + setHelpAiEscalationStage, + setHelpAiChatMeta, + incrementHelpAiIssueCount, +} = helpAiSlice.actions; + +const helpAiPersistConfig = { + key: 'helpAi', + storage: reduxStorage, + whitelist: ['threads', 'activeConversationId'], +}; + +export default persistReducer(helpAiPersistConfig, helpAiSlice.reducer); \ No newline at end of file diff --git a/src/store/store.ts b/src/store/store.ts index 6a4676f2cf..bf33c248fc 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -21,6 +21,7 @@ import cachedTxnReducer from './reducers/cachedTxn'; import signerReducer from './reducers/signer'; import accountReducer from './reducers/account'; import swapReducer from './reducers/swap'; +import helpAiReducer from './reducers/helpAi'; import { RESET_REDUX_STORE } from './sagaActions/upgrade'; import reduxPersistMigrations from './migrations'; @@ -42,6 +43,7 @@ const appReducer = combineReducers({ signer: signerReducer, account: accountReducer, swap: swapReducer, + helpAi: helpAiReducer, }); const rootReducer = (state, action) => { diff --git a/src/utils/helpAiLinkPolicy.ts b/src/utils/helpAiLinkPolicy.ts new file mode 100644 index 0000000000..c48e5b2a01 --- /dev/null +++ b/src/utils/helpAiLinkPolicy.ts @@ -0,0 +1,127 @@ +type HelpAiSourceLink = { title: string; url: string }; + +const HELP_AI_ALLOWED_LINK_DOMAINS = [ + 'bitcoinkeeper.app', + 'tapsigner.com', + 'coldcard.com', + 'seedsigner.com', + 'specter.solutions', + 'ledger.com', + 'trezor.io', + 'bitbox.swiss', + 'blockstream.com', + 'help.blockstream.com', + 'keyst.one', + 'foundation.xyz', + 'satochip.io', + 'store.coinkite.com', + 'docs.coinkite.com', + 'suite.trezor.io', + 'support.ledger.com', + 'youtube.com', + 'coingecko.com', + 'github.com', + 'en.bitcoin.it', + 'medium.com', +]; + +const URL_IN_TEXT_PATTERN = /https?:\/\/[^\s<>()\[\]{}"]+/gi; + +function normalizeHost(host: string): string { + return host.trim().toLowerCase().replace(/\.$/, ''); +} + +function isIPv4Host(host: string): boolean { + const parts = host.split('.'); + if (parts.length !== 4) return false; + + return parts.every((part) => { + if (!/^\d+$/.test(part)) return false; + const value = Number(part); + return value >= 0 && value <= 255; + }); +} + +function isIPv6Host(host: string): boolean { + return host.includes(':'); +} + +function isIpLiteralHost(host: string): boolean { + return isIPv4Host(host) || isIPv6Host(host); +} + +function isLocalhostHost(host: string): boolean { + return host === 'localhost' || host.endsWith('.localhost'); +} + +function isAllowlistedHost(host: string): boolean { + return HELP_AI_ALLOWED_LINK_DOMAINS.some( + (domain) => host === domain || host.endsWith(`.${domain}`) + ); +} + +function isAllowedHelpAiUrl(rawUrl: string): boolean { + let parsed: URL; + + try { + parsed = new URL(rawUrl); + } catch { + return false; + } + + if (parsed.protocol !== 'https:') return false; + + const host = normalizeHost(parsed.hostname || ''); + if (!host) return false; + if (isLocalhostHost(host)) return false; + if (isIpLiteralHost(host)) return false; + + return isAllowlistedHost(host); +} + +function splitTrailingPunctuation(token: string): { + core: string; + trailing: string; +} { + const match = token.match(/[.,!?;:)+\]]+$/); + if (!match) return { core: token, trailing: '' }; + + const trailing = match[0]; + return { + core: token.slice(0, -trailing.length), + trailing, + }; +} + +export function sanitizeHelpAiReplyLinks(text: string): string { + if (!text) return text; + + const sanitized = text.replace(URL_IN_TEXT_PATTERN, (token) => { + const { core, trailing } = splitTrailingPunctuation(token); + return isAllowedHelpAiUrl(core) ? core + trailing : trailing; + }); + + return sanitized.replace(/\s{2,}/g, ' ').trim(); +} + +export function sanitizeHelpAiSources( + sources?: HelpAiSourceLink[], + max = 4 +): HelpAiSourceLink[] { + if (!sources?.length) return []; + + const unique: HelpAiSourceLink[] = []; + const seen = new Set(); + + for (const source of sources) { + if (!source?.url || !isAllowedHelpAiUrl(source.url)) continue; + if (seen.has(source.url)) continue; + + unique.push(source); + seen.add(source.url); + + if (unique.length >= max) break; + } + + return unique; +} diff --git a/src/utils/helpAiSensitiveData.ts b/src/utils/helpAiSensitiveData.ts new file mode 100644 index 0000000000..3d8fd811c2 --- /dev/null +++ b/src/utils/helpAiSensitiveData.ts @@ -0,0 +1,125 @@ +import { HelpDraft } from 'src/models/interfaces/HelpAi'; +import { BIP39_WORD_SET } from 'src/constants/bip39WordSet'; + +export type SensitiveDetectionResult = { + kind: 'mnemonic' | 'extended_private_key' | 'extended_public_key' | 'wif_key'; + message: string; +}; + +// ─── Private detectors ──────────────────────────────────────────────────────── + +/** + * Detects actual extended private key strings (xprv/yprv/zprv + variants) by + * requiring the 4-char prefix to be followed by ≥100 base58 characters. + * The word "xprv" alone, without a long suffix, does NOT trigger this. + */ +const EXTENDED_PRIVATE_KEY_PATTERN = + /\b(xprv|yprv|zprv|Xprv|Yprv|Zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{100,}\b/; + +/** + * Detects actual extended public key strings (xpub/ypub/zpub + variants). + * Same length requirement — the word "xpub" alone does NOT trigger this. + */ +const EXTENDED_PUBLIC_KEY_PATTERN = + /\b(xpub|ypub|zpub|Xpub|Ypub|Zpub|tpub|upub|vpub)[1-9A-HJ-NP-Za-km-z]{100,}\b/; + +/** + * Detects WIF-encoded private keys: + * 5… = mainnet uncompressed (51 chars) + * K… = mainnet compressed (52 chars) + * L… = mainnet compressed (52 chars) + * c… = testnet (52 chars) + */ +const WIF_KEY_PATTERN = /\b[5KLc][1-9A-HJ-NP-Za-km-z]{49,51}\b/; + +/** Minimum number of consecutive BIP39 words to flag as a potential mnemonic. */ +const MNEMONIC_THRESHOLD = 8; + +function detectExtendedPrivateKey(text: string): SensitiveDetectionResult | null { + if (EXTENDED_PRIVATE_KEY_PATTERN.test(text)) { + return { + kind: 'extended_private_key', + message: 'This looks like an extended private key. Please remove it — private keys must never be shared.', + }; + } + return null; +} + +function detectExtendedPublicKey(text: string): SensitiveDetectionResult | null { + if (EXTENDED_PUBLIC_KEY_PATTERN.test(text)) { + return { + kind: 'extended_public_key', + message: 'This looks like an extended public key (xpub). Please remove it — sharing your xpub exposes your full transaction history.', + }; + } + return null; +} + +function detectWIF(text: string): SensitiveDetectionResult | null { + if (WIF_KEY_PATTERN.test(text)) { + return { + kind: 'wif_key', + message: 'This looks like a private key. Please remove it — private keys must never be shared.', + }; + } + return null; +} + +function detectMnemonic(text: string): SensitiveDetectionResult | null { + const tokens = text.toLowerCase().trim().split(/\s+/); + let consecutiveCount = 0; + + for (const token of tokens) { + if (BIP39_WORD_SET.has(token)) { + consecutiveCount++; + if (consecutiveCount >= MNEMONIC_THRESHOLD) { + return { + kind: 'mnemonic', + message: 'This looks like a seed phrase. Please remove it — seed words must never be shared.', + }; + } + } else { + consecutiveCount = 0; + } + } + + return null; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Scans a chat input string for sensitive Bitcoin key material. + * Runs detectors in priority order: extended keys → WIF → mnemonic → keywords. + * + * @returns A detection result describing what was found, or `null` if clean. + */ +export function detectSensitiveInput(text: string): SensitiveDetectionResult | null { + if (!text) return null; + return ( + detectExtendedPrivateKey(text) ?? + detectExtendedPublicKey(text) ?? + detectWIF(text) ?? + detectMnemonic(text) + ); +} + +/** + * Scans all string fields of a `HelpDraft` for sensitive Bitcoin key material. + * Concatenates title, steps, expected, actual, problem, and proposed before scanning. + * + * @returns A detection result describing what was found, or `null` if clean. + */ +export function detectSensitiveInDraft(draft: HelpDraft): SensitiveDetectionResult | null { + const parts: string[] = []; + + if (draft.title) parts.push(draft.title); + if (draft.steps?.length) parts.push(...draft.steps); + if (draft.expected) parts.push(draft.expected); + if (draft.actual) parts.push(draft.actual); + if (draft.problem) parts.push(draft.problem); + if (draft.proposed) parts.push(draft.proposed); + + const combined = parts.join(' '); + return detectSensitiveInput(combined); +} diff --git a/tests/components/TestnetHeaderIndicator.test.tsx b/tests/components/TestnetHeaderIndicator.test.tsx new file mode 100644 index 0000000000..16732e41a3 --- /dev/null +++ b/tests/components/TestnetHeaderIndicator.test.tsx @@ -0,0 +1,165 @@ +jest.mock('src/utils/utilities', () => ({ + capitalizeEachWord: (text: string) => + text + .split(' ') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '), +})); + +jest.mock('src/components/KeeperText', () => { + const React = require('react'); + const { Text } = require('react-native'); + return ({ children, testID = undefined, ...props }) => {children}; +}); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ dispatch: jest.fn(), goBack: jest.fn() }), + useFocusEffect: jest.fn(), + CommonActions: { navigate: jest.fn() }, +})); + +jest.mock('src/hooks/useUaiStack', () => ({ + __esModule: true, + default: () => ({ uaiStack: [] }), + uaiPriorityMap: {}, +})); + +jest.mock('src/hooks/useSignerMap', () => ({ + __esModule: true, + default: () => ({ signerMap: {} }), +})); + +jest.mock('src/hooks/useToastMessage', () => ({ + __esModule: true, + default: () => ({ showToast: jest.fn() }), +})); + +jest.mock('src/components/ThemedColor/ThemedColor', () => ({ + __esModule: true, + default: () => '#000', +})); + +jest.mock('src/components/ThemedSvg.tsx/ThemedSvg', () => { + return () => null; +}); + +jest.mock('src/assets/images/header-notification-simple-icon.svg', () => () => null); +jest.mock('src/assets/images/header-notifications-dot-icon.svg', () => () => null); +jest.mock('src/assets/images/x.svg', () => () => null); + +jest.mock('src/store/reducers/cachedTxn', () => ({ + cachedTxSnapshot: {}, +})); + +jest.mock('src/store/sagaActions/uai', () => ({ + uaiActioned: jest.fn(), + uaisSeen: jest.fn(), +})); + +jest.mock('src/store/sagaActions/bhr', () => ({ + backupAllSignersAndVaults: jest.fn(), +})); + +jest.mock('src/store/reducers/send_and_receive', () => ({ + setStateFromSnapshot: jest.fn(), +})); + +jest.mock('src/store/reducers/uai', () => ({ + setRefreshUai: jest.fn(), +})); + +jest.mock('src/screens/Home/Notifications/NotificationsCenter', () => ({ + getUaiContent: jest.fn(), +})); + +jest.mock('react-native-device-info', () => ({ + getVersion: () => '1.0.0', + getBuildNumber: () => '100', + getSystemName: () => 'iOS', + getUniqueId: () => 'mocked-device-id', + getManufacturer: () => Promise.resolve('MockedManufacturer'), +})); + +import React from 'react'; +import { render } from 'src/utils/test-utils'; +import HomeScreenHeader from 'src/components/HomeScreenHeader'; +import { NetworkType } from 'src/services/wallets/enums'; + +const basePreloadedState = { + settings: { + bitcoinNetworkType: NetworkType.MAINNET, + themeMode: 'LIGHT', + }, + cachedTxn: { snapshots: {} }, + uai: { refreshUai: false }, +}; + +describe('HomeScreenHeader — testnet title suffix', () => { + it('4.1 renders "Wallets (Testnet)" when titleSuffix is passed', () => { + const { getByTestId } = render( + , + { preloadedState: basePreloadedState } + ); + const textEl = getByTestId('text_home_current_plan'); + const content = Array.isArray(textEl.props.children) + ? textEl.props.children.join('') + : String(textEl.props.children ?? ''); + expect(content).toContain('(Testnet)'); + }); + + it('4.2 renders "Keys (Testnet)" when titleSuffix is passed', () => { + const { getByTestId } = render( + , + { preloadedState: basePreloadedState } + ); + const textEl = getByTestId('text_home_current_plan'); + const content = Array.isArray(textEl.props.children) + ? textEl.props.children.join('') + : String(textEl.props.children ?? ''); + expect(content).toContain('(Testnet)'); + }); + + it('4.5 renders "Wallets" without "(Testnet)" when titleSuffix is undefined', () => { + const { getByTestId } = render( + , + { preloadedState: basePreloadedState } + ); + const textEl = getByTestId('text_home_current_plan'); + const content = Array.isArray(textEl.props.children) + ? textEl.props.children.join('') + : String(textEl.props.children ?? ''); + expect(content).not.toContain('Testnet'); + }); + + it('4.5 renders "Keys" without "(Testnet)" when titleSuffix is undefined', () => { + const { getByTestId } = render( + , + { preloadedState: basePreloadedState } + ); + const textEl = getByTestId('text_home_current_plan'); + const content = Array.isArray(textEl.props.children) + ? textEl.props.children.join('') + : String(textEl.props.children ?? ''); + expect(content).not.toContain('Testnet'); + }); +}); diff --git a/tests/helpAiSensitiveData.test.ts b/tests/helpAiSensitiveData.test.ts new file mode 100644 index 0000000000..92b985ba2b --- /dev/null +++ b/tests/helpAiSensitiveData.test.ts @@ -0,0 +1,184 @@ +import { detectSensitiveInput, detectSensitiveInDraft } from 'src/utils/helpAiSensitiveData'; +import type { HelpDraft } from 'src/models/interfaces/HelpAi'; + + +describe('detectSensitiveInput — mnemonic detection', () => { + it('returns kind mnemonic for a valid 12-word BIP39 mnemonic', () => { + const result = detectSensitiveInput( + 'abandon ability able about above absent absorb abstract absurd abuse access accident' + ); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('mnemonic'); + }); + + it('returns kind mnemonic for a valid 24-word BIP39 mnemonic', () => { + const result = detectSensitiveInput( + 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote' + ); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('mnemonic'); + }); + + it('returns kind mnemonic for exactly 8 consecutive BIP39 words', () => { + const result = detectSensitiveInput( + 'abandon ability able about above absent absorb abstract' + ); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('mnemonic'); + }); + + it('returns null for 7 consecutive BIP39 words (below threshold)', () => { + const result = detectSensitiveInput( + 'abandon ability able about above absent absorb' + ); + expect(result).toBeNull(); + }); + + it('returns null for a clean benign support question', () => { + expect(detectSensitiveInput('Why is my transaction pending?')).toBeNull(); + expect(detectSensitiveInput('How do I backup my wallet?')).toBeNull(); + expect(detectSensitiveInput('I need help with my Coldcard setup')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(detectSensitiveInput('')).toBeNull(); + }); + + it('detects mnemonic embedded within surrounding text', () => { + const result = detectSensitiveInput( + 'Here it is: abandon ability able about above absent absorb abstract absurd abuse — those are the first ten' + ); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('mnemonic'); + }); +}); + + +describe('detectSensitiveInput — extended key detection', () => { + const XPRV = + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqhuCh8J' + + 'GefGSVpNfmGhp7DBHubEpK7FKDMbQBMxDMVBZpQoNM6xQnYFT'; + const ZPRV = + 'zprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqhuCh8J' + + 'GefGSVpNfmGhp7DBHubEpK7FKDMbQBMxDMVBZpQoNM6xQnYFT'; + const TPRV = + 'tprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqhuCh8J' + + 'GefGSVpNfmGhp7DBHubEpK7FKDMbQBMxDMVBZpQoNM6xQnYFT'; + const XPUB = + 'xpub6FNxwLzfLRiwdbRUc9xDjEY6dSuv1GsC2VgqYbKpwdRT9j8G7HNAWCpHzaor' + + 'f443eCQMJdtNieTD9MVYEgvRJNJ3MeoePdV8DGGiehsJckKabcd'; + + it('returns kind extended_private_key for a full xprv string', () => { + const result = detectSensitiveInput(`My key is ${XPRV}`); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('extended_private_key'); + }); + + it('returns kind extended_private_key for zprv variant', () => { + const result = detectSensitiveInput(ZPRV); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('extended_private_key'); + }); + + it('returns kind extended_private_key for tprv (testnet) variant', () => { + const result = detectSensitiveInput(TPRV); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('extended_private_key'); + }); + + it('returns kind extended_public_key for a full xpub string', () => { + const result = detectSensitiveInput(XPUB); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('extended_public_key'); + }); + + it('returns null for the word "xpub" alone (no long base58 suffix)', () => { + expect(detectSensitiveInput('How do I find my xpub?')).toBeNull(); + expect(detectSensitiveInput('I read about xpub in the docs')).toBeNull(); + }); + + it('returns null for the word "xprv" alone', () => { + expect(detectSensitiveInput('I read about xprv format in BIP32')).toBeNull(); + }); +}); + + +describe('detectSensitiveInput — WIF key detection', () => { + // Realistic-length WIF keys (52 chars for K/L, 51 for 5) + const WIF_K = 'KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73NUTaaa'; + // WIF uncompressed mainnet: starts with 5, total 51 chars, valid base58 only (no 0,O,I,l) + const WIF_5 = '5HueCGU8rMjxECyDiaxwujn7vByAa7MqVZhA9YQCLGwBBmZabbb'; + + it('returns kind wif_key for a WIF key starting with K', () => { + const result = detectSensitiveInput(`My WIF: ${WIF_K}`); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('wif_key'); + }); + + it('returns kind wif_key for a WIF key starting with 5', () => { + const result = detectSensitiveInput(WIF_5); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('wif_key'); + }); +}); + + +describe('detectSensitiveInDraft', () => { + it('returns a result when the title field contains a BIP39 mnemonic sequence', () => { + const draft: HelpDraft = { + kind: 'bug', + title: 'abandon ability able about above absent absorb abstract', + }; + const result = detectSensitiveInDraft(draft); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('mnemonic'); + }); + + it('returns a result when a steps entry contains a BIP39 mnemonic sequence', () => { + const draft: HelpDraft = { + kind: 'bug', + title: 'Cannot restore wallet', + steps: [ + 'Open the app', + 'abandon ability able about above absent absorb abstract absurd abuse', + 'Tap restore', + ], + }; + const result = detectSensitiveInDraft(draft); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('mnemonic'); + }); + + it('returns a result when the problem field contains an xpub key', () => { + const xpub = + 'xpub6FNxwLzfLRiwdbRUc9xDjEY6dSuv1GsC2VgqYbKpwdRT9j8G7HNAWCpHzaor' + + 'f443eCQMJdtNieTD9MVYEgvRJNJ3MeoePdV8DGGiehsJckKabcd'; + const draft: HelpDraft = { + kind: 'bug', + title: 'Watch-only wallet issue', + problem: `I pasted ${xpub} but the wallet does not load`, + }; + const result = detectSensitiveInDraft(draft); + expect(result).not.toBeNull(); + expect(result?.kind).toBe('extended_public_key'); + }); + + it('returns null for a clean draft with no sensitive content', () => { + const draft: HelpDraft = { + kind: 'bug', + title: 'Transaction stuck in mempool', + steps: ['Open wallet', 'Tap Send', 'Observe pending status'], + expected: 'Transaction confirms within 10 minutes', + actual: 'Transaction shows pending after 2 hours', + }; + expect(detectSensitiveInDraft(draft)).toBeNull(); + }); + + it('returns null for an empty draft', () => { + const draft: HelpDraft = { + kind: 'feature', + title: '', + }; + expect(detectSensitiveInDraft(draft)).toBeNull(); + }); +});