From 795d32e538a6f6535d82cc42403162df5c33850f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 12:46:36 +0000 Subject: [PATCH] feat(ai-partner): first-use privacy opt-in flow (#1458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before the first Amicus query, users now see a one-time modal that clearly discloses what leaves the device. The modal is backed by a React context so any caller can `await requestAmicusConsent()` before hitting the proxy. New files: - components/amicus/AmicusFirstUseModal.tsx — the modal. Cinzel title, EB Garamond body, two CTAs + privacy-policy link. Android hardware back triggers decline. - services/amicus/consent.tsx — storage helpers (hasAccepted / accept / reset) + AmicusConsentProvider context + useAmicusConsent hook. Pending requests resolve true on accept, false on decline. Wired: - App.tsx — provider mounted above AppShell so every screen sees the context. - screens/AmicusThreadScreen — handleSend now awaits consent before streaming. Declining aborts the send without writing the pref, so the user can try again later. 9 new tests cover: modal copy + primary / secondary tap, hidden state when visible=false, hasAcceptedAmicusOptIn with valid / missing / empty values, accept writes a timestamp, reset writes empty string. Full suite 3,317 / 3,317 passing; tsc clean. https://claude.ai/code/session_01Pht3kzgdvkn81DDfL9SnFe --- app/App.tsx | 5 +- .../components/amicus/AmicusFirstUseModal.tsx | 162 ++++++++++++++++++ .../__tests__/AmicusFirstUseModal.test.tsx | 60 +++++++ app/src/screens/AmicusThreadScreen.tsx | 10 +- .../services/amicus/__tests__/consent.test.ts | 65 +++++++ app/src/services/amicus/consent.tsx | 112 ++++++++++++ 6 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 app/src/components/amicus/AmicusFirstUseModal.tsx create mode 100644 app/src/components/amicus/__tests__/AmicusFirstUseModal.test.tsx create mode 100644 app/src/services/amicus/__tests__/consent.test.ts create mode 100644 app/src/services/amicus/consent.tsx diff --git a/app/App.tsx b/app/App.tsx index afaa5dda7..27e21d99a 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -28,6 +28,7 @@ import { useNotificationRouter } from './src/hooks/useNotificationRouter'; import { ErrorBoundary } from './src/components/ErrorBoundary'; import { closeAllTranslationDbs } from './src/db/translationManager'; import { ContentUpdateProvider } from './src/providers/ContentUpdateProvider'; +import { AmicusConsentProvider } from './src/services/amicus/consent'; import { DbDownloadScreen } from './src/screens/DbDownloadScreen'; import { Sentry, DSN } from './src/lib/sentry'; @@ -221,7 +222,9 @@ function App() { - + + + diff --git a/app/src/components/amicus/AmicusFirstUseModal.tsx b/app/src/components/amicus/AmicusFirstUseModal.tsx new file mode 100644 index 000000000..f87a70c49 --- /dev/null +++ b/app/src/components/amicus/AmicusFirstUseModal.tsx @@ -0,0 +1,162 @@ +/** + * components/amicus/AmicusFirstUseModal.tsx — one-time privacy disclosure + * shown the first time a user sends an Amicus query (#1458). + */ +import React from 'react'; +import { + BackHandler, + Linking, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { MessageSquare } from 'lucide-react-native'; +import { fontFamily, spacing, useTheme } from '../../theme'; + +export interface AmicusFirstUseModalProps { + visible: boolean; + onAccept: () => void; + onDecline: () => void; + /** Overridable for tests. */ + privacyPolicyUrl?: string; +} + +const DEFAULT_PRIVACY_URL = 'https://contentcompanionstudy.com/privacy'; + +export default function AmicusFirstUseModal( + props: AmicusFirstUseModalProps, +): React.ReactElement { + const { base } = useTheme(); + + React.useEffect(() => { + if (!props.visible) return undefined; + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + props.onDecline(); + return true; + }); + return () => sub.remove(); + }, [props]); + + const openPrivacy = (): void => { + void Linking.openURL(props.privacyPolicyUrl ?? DEFAULT_PRIVACY_URL); + }; + + return ( + + + + + + + + + Meet Amicus + + + Before we get started + + + Amicus is your scholarly study companion. It answers questions by drawing on + the curated Companion Study corpus — our 72 scholars, word studies, debates, + and cross-references. It never fabricates scholar positions. + + + What stays on your device + + + • Your notes, highlights, and bookmarks{'\n'} + • Your full reading history{'\n'} + • Your Amicus conversations + + + What gets sent to our AI provider when you ask a question + + + • Your question text{'\n'} + • An abstract summary of your reading patterns{'\n'} + • The retrieved scholarly content your question is answered from + + + You can inspect exactly what gets sent in Settings → Amicus → Show My + Profile. + + + Our AI provider has a zero-retention commitment. Your data is never used to + train models. + + + + + + + I understand, let’s begin + + + + Not now + + + + Read our full privacy commitment + + + + + + + ); +} + +const styles = StyleSheet.create({ + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'flex-end' }, + card: { + maxHeight: '90%', + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + }, + content: { padding: spacing.lg, paddingBottom: spacing.md, gap: spacing.sm }, + iconRow: { alignItems: 'center', marginTop: spacing.sm }, + title: { fontSize: 22, textAlign: 'center' }, + subtitle: { fontSize: 14, textAlign: 'center', marginBottom: spacing.md }, + heading: { fontSize: 14, marginTop: spacing.md }, + body: { fontSize: 15, lineHeight: 22 }, + bullet: { fontSize: 15, lineHeight: 22 }, + footer: { + padding: spacing.md, + borderTopWidth: StyleSheet.hairlineWidth, + gap: spacing.sm, + }, + primary: { paddingVertical: 14, borderRadius: 999, alignItems: 'center' }, + primaryText: { fontSize: 15 }, + secondary: { alignItems: 'center', paddingVertical: 8 }, + secondaryText: { fontSize: 13 }, + link: { alignItems: 'center' }, + linkText: { fontSize: 12, textDecorationLine: 'underline' }, +}); diff --git a/app/src/components/amicus/__tests__/AmicusFirstUseModal.test.tsx b/app/src/components/amicus/__tests__/AmicusFirstUseModal.test.tsx new file mode 100644 index 000000000..8a9005e72 --- /dev/null +++ b/app/src/components/amicus/__tests__/AmicusFirstUseModal.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react-native'; +import { renderWithProviders } from '../../../../__tests__/helpers/renderWithProviders'; +import AmicusFirstUseModal from '@/components/amicus/AmicusFirstUseModal'; + +describe('AmicusFirstUseModal', () => { + it('renders all required disclosure copy when visible', () => { + const { getByText } = renderWithProviders( + undefined} + onDecline={() => undefined} + />, + ); + expect(getByText('Meet Amicus')).toBeTruthy(); + expect(getByText('What stays on your device')).toBeTruthy(); + expect( + getByText(/What gets sent to our AI provider when you ask a question/), + ).toBeTruthy(); + expect(getByText(/zero-retention commitment/)).toBeTruthy(); + }); + + it('invokes onAccept on primary button tap', () => { + const onAccept = jest.fn(); + const { getByLabelText } = renderWithProviders( + undefined} + />, + ); + fireEvent.press(getByLabelText("I understand, let's begin")); + expect(onAccept).toHaveBeenCalledTimes(1); + }); + + it('invokes onDecline on "Not now" tap', () => { + const onDecline = jest.fn(); + const { getByLabelText } = renderWithProviders( + undefined} + onDecline={onDecline} + />, + ); + fireEvent.press(getByLabelText('Not now')); + expect(onDecline).toHaveBeenCalledTimes(1); + }); + + it('renders nothing when not visible', () => { + const { queryByText } = render( + undefined} + onDecline={() => undefined} + />, + ); + // Modal component may not render content to the tree when visible=false. + expect(queryByText('Meet Amicus')).toBeNull(); + }); +}); diff --git a/app/src/screens/AmicusThreadScreen.tsx b/app/src/screens/AmicusThreadScreen.tsx index 70c831a24..0e4d6b4f6 100644 --- a/app/src/screens/AmicusThreadScreen.tsx +++ b/app/src/screens/AmicusThreadScreen.tsx @@ -26,6 +26,7 @@ import { navigateToCitation, type MetaFaqArticle, } from '../services/amicus/citationNav'; +import { useAmicusConsent } from '../services/amicus/consent'; import type { AmicusCitation, AmicusThread } from '../types'; import type { ScreenNavProp, ScreenRouteProp } from '../navigation/types'; import { logger } from '../utils/logger'; @@ -38,6 +39,7 @@ export default function AmicusThreadScreen(): React.ReactElement { const [thread, setThread] = useState(null); const [faqArticle, setFaqArticle] = useState(null); + const { requestAmicusConsent } = useAmicusConsent(); const { messages, isStreaming, error, sendMessage, abortStream, clearError } = useAmicusThread(threadId); @@ -64,9 +66,15 @@ export default function AmicusThreadScreen(): React.ReactElement { logger.warn('Amicus', 'no auth token — aborting send'); return; } + // Gate on one-time privacy acknowledgement (#1458). + const accepted = await requestAmicusConsent(); + if (!accepted) { + logger.info('Amicus', 'opt-in declined — not sending'); + return; + } await sendMessage(text, authToken); }, - [sendMessage], + [sendMessage, requestAmicusConsent], ); const handleCitation = useCallback( diff --git a/app/src/services/amicus/__tests__/consent.test.ts b/app/src/services/amicus/__tests__/consent.test.ts new file mode 100644 index 000000000..bd0844c09 --- /dev/null +++ b/app/src/services/amicus/__tests__/consent.test.ts @@ -0,0 +1,65 @@ +/** + * Tests for services/amicus/consent.ts (pure helpers). + */ +import { getMockUserDb, resetMockUserDb } from '../../../../__tests__/helpers/mockUserDb'; + +jest.mock('@/db/userDatabase', () => + require('../../../../__tests__/helpers/mockUserDb').mockUserDatabaseModule(), +); + +import { + AMICUS_OPT_IN_KEY, + acceptAmicusOptIn, + hasAcceptedAmicusOptIn, + resetAmicusOptIn, +} from '../consent'; + +beforeEach(() => { + resetMockUserDb(); +}); + +describe('hasAcceptedAmicusOptIn', () => { + it('returns true when a valid timestamp is stored', async () => { + getMockUserDb().getFirstAsync.mockResolvedValueOnce({ + value: '2026-04-17T10:00:00.000Z', + }); + expect(await hasAcceptedAmicusOptIn()).toBe(true); + }); + + it('returns false when the pref is absent', async () => { + getMockUserDb().getFirstAsync.mockResolvedValueOnce(null); + expect(await hasAcceptedAmicusOptIn()).toBe(false); + }); + + it('returns false when the pref has been reset (empty string)', async () => { + getMockUserDb().getFirstAsync.mockResolvedValueOnce({ value: '' }); + expect(await hasAcceptedAmicusOptIn()).toBe(false); + }); +}); + +describe('acceptAmicusOptIn', () => { + it('writes the current timestamp under the opt-in key', async () => { + await acceptAmicusOptIn(); + const calls = getMockUserDb().runAsync.mock.calls; + const writeCall = calls.find((c: unknown[]) => + typeof c[0] === 'string' && c[0].includes('user_preferences'), + ); + expect(writeCall).toBeTruthy(); + const params = writeCall?.[1] as [string, string]; + expect(params[0]).toBe(AMICUS_OPT_IN_KEY); + expect(Date.parse(params[1])).not.toBeNaN(); + }); +}); + +describe('resetAmicusOptIn', () => { + it('writes an empty string to the opt-in key', async () => { + await resetAmicusOptIn(); + const writeCall = getMockUserDb().runAsync.mock.calls.find( + (c: unknown[]) => + typeof c[0] === 'string' && c[0].includes('user_preferences'), + ); + const params = writeCall?.[1] as [string, string]; + expect(params[0]).toBe(AMICUS_OPT_IN_KEY); + expect(params[1]).toBe(''); + }); +}); diff --git a/app/src/services/amicus/consent.tsx b/app/src/services/amicus/consent.tsx new file mode 100644 index 000000000..84c056844 --- /dev/null +++ b/app/src/services/amicus/consent.tsx @@ -0,0 +1,112 @@ +/** + * services/amicus/consent.ts — First-use consent storage + a tiny React + * context provider that exposes `requestAmicusConsent()` as a promise. + * + * The provider mounts AmicusFirstUseModal once anywhere in the tree; any + * screen inside the provider can await consent before calling the proxy. + */ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from 'react'; +import AmicusFirstUseModal from '../../components/amicus/AmicusFirstUseModal'; +import { getPreference } from '../../db/userQueries'; +import { setPreference } from '../../db/userMutations'; +import { logger } from '../../utils/logger'; + +export const AMICUS_OPT_IN_KEY = 'amicus_opt_in_accepted_at'; + +export async function hasAcceptedAmicusOptIn(): Promise { + const value = await getPreference(AMICUS_OPT_IN_KEY); + return typeof value === 'string' && value.length > 0; +} + +export async function acceptAmicusOptIn(): Promise { + await setPreference(AMICUS_OPT_IN_KEY, new Date().toISOString()); + logger.info('Amicus', 'opt-in accepted'); +} + +export async function resetAmicusOptIn(): Promise { + await setPreference(AMICUS_OPT_IN_KEY, ''); + logger.info('Amicus', 'opt-in reset'); +} + +// ── React context ──────────────────────────────────────────────────── + +interface ConsentContextValue { + /** Returns `true` if consent was just accepted (or was already on file). */ + requestAmicusConsent: () => Promise; +} + +const ConsentContext = createContext(null); + +interface PendingRequest { + resolve: (accepted: boolean) => void; +} + +export function AmicusConsentProvider({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement { + const [visible, setVisible] = useState(false); + const pendingRef = useRef(null); + + const handleAccept = useCallback(async () => { + setVisible(false); + try { + await acceptAmicusOptIn(); + } catch (err) { + logger.error('Amicus', 'opt-in write failed', err); + } + pendingRef.current?.resolve(true); + pendingRef.current = null; + }, []); + + const handleDecline = useCallback(() => { + setVisible(false); + pendingRef.current?.resolve(false); + pendingRef.current = null; + }, []); + + const requestAmicusConsent = useCallback(async (): Promise => { + if (await hasAcceptedAmicusOptIn()) return true; + return new Promise((resolve) => { + pendingRef.current?.resolve(false); + pendingRef.current = { resolve }; + setVisible(true); + }); + }, []); + + const ctx = useMemo( + () => ({ requestAmicusConsent }), + [requestAmicusConsent], + ); + + return ( + + {children} + void handleAccept()} + onDecline={handleDecline} + /> + + ); +} + +export function useAmicusConsent(): ConsentContextValue { + const ctx = useContext(ConsentContext); + if (!ctx) { + // A safe fallback means an over-cautious consent flow rather than a + // crash when the provider isn't mounted (e.g. in test harnesses). + return { + requestAmicusConsent: async () => hasAcceptedAmicusOptIn(), + }; + } + return ctx; +}