Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -221,7 +222,9 @@ function App() {
<SafeAreaProvider>
<ThemeProvider>
<ContentUpdateProvider>
<AppShell />
<AmicusConsentProvider>
<AppShell />
</AmicusConsentProvider>
</ContentUpdateProvider>
</ThemeProvider>
</SafeAreaProvider>
Expand Down
162 changes: 162 additions & 0 deletions app/src/components/amicus/AmicusFirstUseModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal
visible={props.visible}
animationType="fade"
transparent
onRequestClose={props.onDecline}
accessibilityLabel="Amicus first-use privacy notice"
>
<View style={styles.backdrop}>
<SafeAreaView
accessibilityViewIsModal
style={[styles.card, { backgroundColor: base.bg }]}
>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.iconRow}>
<MessageSquare size={36} color={base.gold} />
</View>
<Text style={[styles.title, { color: base.text, fontFamily: fontFamily.display }]}>
Meet Amicus
</Text>
<Text
style={[styles.subtitle, { color: base.textMuted, fontFamily: fontFamily.bodyItalic }]}
>
Before we get started
</Text>
<Text style={[styles.body, { color: base.text, fontFamily: fontFamily.body }]}>
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.
</Text>
<Text style={[styles.heading, { color: base.text, fontFamily: fontFamily.displaySemiBold }]}>
What stays on your device
</Text>
<Text style={[styles.bullet, { color: base.text, fontFamily: fontFamily.body }]}>
• Your notes, highlights, and bookmarks{'\n'}
• Your full reading history{'\n'}
• Your Amicus conversations
</Text>
<Text style={[styles.heading, { color: base.text, fontFamily: fontFamily.displaySemiBold }]}>
What gets sent to our AI provider when you ask a question
</Text>
<Text style={[styles.bullet, { color: base.text, fontFamily: fontFamily.body }]}>
• Your question text{'\n'}
• An abstract summary of your reading patterns{'\n'}
• The retrieved scholarly content your question is answered from
</Text>
<Text style={[styles.body, { color: base.text, fontFamily: fontFamily.body }]}>
You can inspect exactly what gets sent in Settings → Amicus → Show My
Profile.
</Text>
<Text style={[styles.body, { color: base.text, fontFamily: fontFamily.body }]}>
Our AI provider has a zero-retention commitment. Your data is never used to
train models.
</Text>
</ScrollView>

<View style={[styles.footer, { borderTopColor: base.border }]}>
<Pressable
accessibilityLabel="I understand, let's begin"
onPress={props.onAccept}
style={[styles.primary, { backgroundColor: base.gold }]}
>
<Text style={[styles.primaryText, { color: base.bg, fontFamily: fontFamily.displaySemiBold }]}>
I understand, let&rsquo;s begin
</Text>
</Pressable>
<Pressable
accessibilityLabel="Not now"
onPress={props.onDecline}
style={styles.secondary}
>
<Text style={[styles.secondaryText, { color: base.textMuted }]}>Not now</Text>
</Pressable>
<Pressable
accessibilityLabel="Read our full privacy commitment"
onPress={openPrivacy}
style={styles.link}
>
<Text style={[styles.linkText, { color: base.gold }]}>
Read our full privacy commitment
</Text>
</Pressable>
</View>
</SafeAreaView>
</View>
</Modal>
);
}

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' },
});
60 changes: 60 additions & 0 deletions app/src/components/amicus/__tests__/AmicusFirstUseModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AmicusFirstUseModal
visible
onAccept={() => 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(
<AmicusFirstUseModal
visible
onAccept={onAccept}
onDecline={() => 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(
<AmicusFirstUseModal
visible
onAccept={() => undefined}
onDecline={onDecline}
/>,
);
fireEvent.press(getByLabelText('Not now'));
expect(onDecline).toHaveBeenCalledTimes(1);
});

it('renders nothing when not visible', () => {
const { queryByText } = render(
<AmicusFirstUseModal
visible={false}
onAccept={() => undefined}
onDecline={() => undefined}
/>,
);
// Modal component may not render content to the tree when visible=false.
expect(queryByText('Meet Amicus')).toBeNull();
});
});
10 changes: 9 additions & 1 deletion app/src/screens/AmicusThreadScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,6 +39,7 @@ export default function AmicusThreadScreen(): React.ReactElement {

const [thread, setThread] = useState<AmicusThread | null>(null);
const [faqArticle, setFaqArticle] = useState<MetaFaqArticle | null>(null);
const { requestAmicusConsent } = useAmicusConsent();
const { messages, isStreaming, error, sendMessage, abortStream, clearError } =
useAmicusThread(threadId);

Expand All @@ -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(
Expand Down
65 changes: 65 additions & 0 deletions app/src/services/amicus/__tests__/consent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Tests for services/amicus/consent.ts (pure helpers).
*/
import { getMockUserDb, resetMockUserDb } from '../../../../__tests__/helpers/mockUserDb';

Check warning on line 4 in app/src/services/amicus/__tests__/consent.test.ts

View workflow job for this annotation

GitHub Actions / lint

There should be no empty line between import groups

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('');
});
});
Loading
Loading