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
3 changes: 3 additions & 0 deletions apps/mobile/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp
- Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`).
- One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored.
- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts` and `sparring.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats and judged Sparring rounds) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value.
- Push: `push.ts` asks for permission and files the Expo push token under the signed-in user with the `register_push_token` RPC; the You tab's Notifications switch turns it on and off, the root layout registers again on launch and when the language changes, and sign-out releases the registration with the account's own access token, after any registration still in flight. Every registration carries the ID of the local record, and `release_push_token` withdraws that ID and deletes only the rows it wrote, so a registration the app gave up on is refused if it reaches the database after the release. Once a release is sent for an ID, the record is marked released and the next registration gets a new ID, so a late release cannot delete it. The local record is cleared only once a release succeeds; otherwise the next change on the phone, from any account, releases it first. The server side is in [supabase/README.md](../../supabase/README.md#push-notifications). `push.web.ts` registers nothing and only flips the switch for the screenshot script.
- No build can get a token until `app.config.ts` has `extra.eas.projectId`, from `eas init` on build day (see [docs/development.md](../../docs/development.md#build-day)). Until then the switch shows as unavailable, on Expo Go too. Expo Go on Android has no remote push since SDK 53, and Expo recommends a development build for remote push on iOS as well.
- Prebuild applies the `expo-notifications` config plugin automatically once the package is installed, even though `app.config.ts` does not list it, and the plugin sets the `aps-environment` entitlement. `DEBATELAB_BUILD=dev` removes it again (`withoutPushEntitlement`), because the borrowed Signulous profile has no push, so a development build cannot get a token even with a project ID: turning the switch on there fails. Real push needs a provisioning profile with the Push Notifications capability and an APNs key uploaded to EAS, both from Adam's own Apple Developer account; only then can `withoutPushEntitlement` go.
- `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts.
- `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs.

Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"expo-linking": "~57.0.12",
"expo-localization": "~57.0.2",
"expo-network": "~57.0.2",
"expo-notifications": "~57.0.22",
"expo-print": "~57.0.2",
"expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4",
Expand Down
111 changes: 77 additions & 34 deletions apps/mobile/scripts/screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
* PORT=<free port> npx tsx apps/mobile/scripts/screenshots.ts
*
* ONLY=iphone-light-en limits a run to the combinations whose name starts with it.
* SCREENS=you,timer limits the signed-in screens to those groups: debate,
* library, prepare, practice, you, empty, ask, results, brainstorm, factoids,
* timer, crossfire, sparring, flowchart, one-off. Sign-in screens are always shot.
* ONLY=offline runs only the offline reading check (see shootOfflineScreens).
*
* Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the
Expand Down Expand Up @@ -75,6 +78,8 @@ const OUT = join(__dirname, '../../../.verify/mobile-shots');
const PORT = Number(process.env.PORT);
const DEMO_CODE = process.env.STAGING_DEMO_CODE?.trim();
const ONLY = process.env.ONLY?.trim() ?? '';
const SCREENS = process.env.SCREENS?.split(',').map((name) => name.trim()).filter(Boolean);
const want = (group: string) => !SCREENS?.length || SCREENS.includes(group);
const ORIGIN = `http://127.0.0.1:${PORT}`;
const STAGING_HOST = new URL(SUPABASE_PROJECTS.staging.url).host;
const SESSION_KEY = `sb-${STAGING_HOST.split('.')[0]}-auth-token`;
Expand All @@ -86,6 +91,7 @@ const DEVICES = {
const SCHEMES = ['light', 'dark'] as const;
const LOCALES = { en: 'en-GB', hu: 'hu-HU' } as const;
const SIGNED_IN_ROUTES = { prepare: '/prepare', practice: '/practice', you: '/you' };
const NOTIFICATIONS = { en: 'Notifications', hu: 'Értesítések' } as const;
const REST = `https://${STAGING_HOST}/rest/v1`;

const TYPES: Record<string, string> = {
Expand Down Expand Up @@ -1079,7 +1085,13 @@ async function signIn(browser: Browser, problems: string[]): Promise<StorageStat
await page.getByRole('tab', { name: 'Demo code' }).click();
await page.getByRole('textbox').fill(DEMO_CODE ?? '');
await page.getByRole('button', { name: 'Sign in with demo code' }).click();
await page.getByRole('tab', { name: 'Debate' }).waitFor({ timeout: 30_000 });
await page.getByRole('tab', { name: 'Debate' }).waitFor({ timeout: 30_000 }).catch(async (error: unknown) => {
const alert = await page.getByRole('alert').first().textContent({ timeout: 1_000 }).catch(() => null);
// The field shows the code in plain text; empty it so the screenshot never holds it.
await page.getByRole('textbox').fill('');
await shoot(page, 'sign-in-failed');
throw new Error(`Demo-code sign-in did not finish${alert ? `: ${alert}` : ''}`, { cause: error });
});
if (authHosts.length === 0 || authHosts.some((host) => host !== STAGING_HOST)) {
throw new Error(`Sign-in reached ${[...new Set(authHosts)].join(', ') || 'no backend'}, expected ${STAGING_HOST}`);
}
Expand Down Expand Up @@ -1372,51 +1384,82 @@ async function main() {
const tabPage = await signedIn.newPage();
watchErrors(tabPage, problems, `${prefix} signed in`);
await pinSettings(tabPage, language);
await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => {
await shoot(tabPage, `${prefix}-failed`);
throw error;
});
shots += 6;
await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => {
await shoot(tabPage, `${prefix}-failed`);
throw error;
});
shots += 5;
if (want('debate')) {
await shootDebateScreens(tabPage, prefix).catch(async (error: unknown) => {
await shoot(tabPage, `${prefix}-failed`);
throw error;
});
shots += 6;
}
if (want('library')) {
await shootLibraryScreens(tabPage, prefix).catch(async (error: unknown) => {
await shoot(tabPage, `${prefix}-failed`);
throw error;
});
shots += 5;
}
for (const [name, route] of Object.entries(SIGNED_IN_ROUTES)) {
if (!want(name)) continue;
await tabPage.goto(`${ORIGIN}${route}`);
await tabPage.getByRole('tablist').first().waitFor();
if (name === 'you') await tabPage.getByTestId('subscription-tier').waitFor({ timeout: 30_000 });
await settle(tabPage);
await shoot(tabPage, `${prefix}-${name}`);
shots += 1;
if (name === 'you') {
// The web preview turns notifications on without registering anything (push.web.ts).
const notifications = tabPage.getByRole('switch', { name: NOTIFICATIONS[language as keyof typeof NOTIFICATIONS] });
await notifications.scrollIntoViewIfNeeded();
await notifications.click();
await tabPage.waitForFunction(() => document.querySelector('[role="switch"]:checked') !== null);
await shoot(tabPage, `${prefix}-you-notifications-on`);
shots += 1;
}
}
if (want('empty')) {
const emptyPage = await signedIn.newPage();
watchErrors(emptyPage, problems, `${prefix} empty`);
await pinSettings(emptyPage, language);
await shootEmptyDebate(emptyPage, prefix);
shots += 2;
}
if (want('ask')) {
await shootAskScreens(signedIn, prefix, language, problems);
shots += 3;
}
if (want('results')) {
await shootResultScreens(signedIn, prefix, language, problems);
shots += 9;
}
const emptyPage = await signedIn.newPage();
watchErrors(emptyPage, problems, `${prefix} empty`);
await pinSettings(emptyPage, language);
await shootEmptyDebate(emptyPage, prefix);
shots += 2;
await shootAskScreens(signedIn, prefix, language, problems);
shots += 3;
await shootResultScreens(signedIn, prefix, language, problems);
shots += 9;
for (const tool of ['brainstorm', 'factoids'] as const) {
if (!want(tool)) continue;
await shootTool(signedIn, prefix, language, problems, tool);
shots += 6;
}
await shootTimerScreens(signedIn, prefix, language, problems);
shots += 3;
await shootCrossfireScreens(signedIn, prefix, language, problems);
shots += 3;
await shootSparringScreens(signedIn, prefix, language, problems);
shots += 6;
await shootFlowchart(signedIn, prefix, language, problems);
shots += 10;
const oneOffPage = await signedIn.newPage();
watchErrors(oneOffPage, problems, `${prefix} one-off tools`);
await pinSettings(oneOffPage, language);
await shootOneOffTools(oneOffPage, prefix);
await oneOffPage.close();
shots += 2;
if (want('timer')) {
await shootTimerScreens(signedIn, prefix, language, problems);
shots += 3;
}
if (want('crossfire')) {
await shootCrossfireScreens(signedIn, prefix, language, problems);
shots += 3;
}
if (want('sparring')) {
await shootSparringScreens(signedIn, prefix, language, problems);
shots += 6;
}
if (want('flowchart')) {
await shootFlowchart(signedIn, prefix, language, problems);
shots += 10;
}
if (want('one-off')) {
const oneOffPage = await signedIn.newPage();
watchErrors(oneOffPage, problems, `${prefix} one-off tools`);
await pinSettings(oneOffPage, language);
await shootOneOffTools(oneOffPage, prefix);
await oneOffPage.close();
shots += 2;
}
await signedIn.close();
}
}
Expand Down
67 changes: 65 additions & 2 deletions apps/mobile/src/app/(tabs)/(you)/you.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { APP_LANGUAGES, type AppLanguage } from '@debatelab/core/languages';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Pressable, StyleSheet, View } from 'react-native';
import { ActivityIndicator, AppState, Linking, Pressable, StyleSheet, View } from 'react-native';

import { TextButton } from '@/components/buttons';
import { Card, IconTile } from '@/components/card';
import { SwitchRow } from '@/components/form-rows';
import { Icon } from '@/components/icon';
import { ScreenTitle } from '@/components/screen-title';
import { Screen } from '@/components/screen';
import { SegmentedControl } from '@/components/segmented-control';
import { Text } from '@/components/text';
import { useAuth } from '@/lib/auth';
import { formatDate } from '@/lib/dates';
import { disablePush, enablePush, readPushState, type PushState } from '@/lib/push';
import { createPushSwitch, type PushSwitchView } from '@/lib/push-switch';
import { useSettings, type ThemePreference } from '@/lib/settings';
import { fetchSubscription, type Subscription, type SubscriptionTier } from '@/lib/subscription';
import { supabaseEnv } from '@/lib/supabase-config';
Expand Down Expand Up @@ -90,6 +93,8 @@ export default function YouScreen() {
) : null}
</Card>

<NotificationsCard userId={session?.user.id} />

<Text accessibilityRole="header" weight="bold" style={styles.section}>
{t('settings.account')}
</Text>
Expand Down Expand Up @@ -144,6 +149,63 @@ function Setting({ label, children }: { label: string; children: ReactNode }) {
);
}

const PUSH_DESCRIPTIONS: Record<PushState, string> = {
on: 'mobile.you.notifications.description',
off: 'mobile.you.notifications.description',
blocked: 'mobile.you.notifications.blocked',
unavailable: 'mobile.you.notifications.unavailable',
};

/** Push notifications for this account on this phone (lib/push.ts). */
function NotificationsCard({ userId }: { userId: string | undefined }) {
const { t } = useTranslation();
const { colors } = useTheme();
const { language } = useSettings();
const [{ state, busy, failed }, setView] = useState<PushSwitchView>({ state: null, busy: false, failed: false });
const [pushSwitch] = useState(() => createPushSwitch((patch) => setView((view) => ({ ...view, ...patch }))));

const load = useCallback(() => {
if (userId) pushSwitch.load(() => readPushState(userId));
}, [userId, pushSwitch]);

// The switch may have changed in iOS Settings while DebateLab was in the background.
useFocusEffect(load);
useEffect(() => {
const subscription = AppState.addEventListener('change', (next) => {
if (next === 'active') load();
});
return () => subscription.remove();
}, [load]);

const change = (on: boolean) => {
if (!userId) return;
pushSwitch.change(() => (on ? enablePush(userId, language) : disablePush(userId).then((): PushState => 'off')));
};

return (
<Card style={styles.list}>
<SwitchRow
first
label={t('mobile.you.notifications.title')}
description={t(PUSH_DESCRIPTIONS[state ?? 'off'])}
value={state === 'on'}
onChange={change}
disabled={busy || state === null || state === 'unavailable' || state === 'blocked'}
/>
{state === 'blocked' ? (
<View style={styles.inset}>
<TextButton label={t('mobile.you.notifications.openSettings')} onPress={() => void Linking.openSettings()} />
</View>
) : null}
{failed ? (
<Text accessibilityRole="alert" weight="medium" style={[styles.small, styles.inset, { color: colors.destructive }]}>
{t('mobile.you.notifications.failed')}
</Text>
) : null}
</Card>
);
}

function SubscriptionCard({ userId }: { userId: string | undefined }) {
const { t } = useTranslation();
const { colors } = useTheme();
Expand Down Expand Up @@ -245,4 +307,5 @@ const styles = StyleSheet.create({
rowTitle: { fontSize: 16 },
email: { fontSize: 17 },
pressed: { opacity: 0.6 },
inset: { paddingHorizontal: 16, paddingBottom: 12 },
});
12 changes: 11 additions & 1 deletion apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { AuthProvider, useAuth } from '@/lib/auth';
import { DebateProvider } from '@/lib/debate-context';
import { watchNetwork } from '@/lib/network-status';
import { refreshPush } from '@/lib/push';
import { SettingsProvider, useSettings } from '@/lib/settings';
import { interFonts } from '@/theme/fonts';
import { useTheme } from '@/theme/use-theme';
Expand All @@ -33,7 +34,7 @@ export default function RootLayout() {
function Root() {
const [fontsLoaded, fontError] = useFonts(interFonts);
const { session, loading, settled } = useAuth();
const { ready: settingsReady } = useSettings();
const { ready: settingsReady, language } = useSettings();
const { scheme, colors } = useTheme();
// Settings come from the device here, so the app opens in the account's language and theme.
const ready = (fontsLoaded || fontError !== null) && !loading && settingsReady;
Expand All @@ -45,6 +46,15 @@ function Root() {
// Offline, reads answer from saved copies and the screens say so.
useEffect(() => watchNetwork(), []);

// A phone that opted in registers again on launch and when the language changes (see refreshPush).
const userId = session?.user.id;
useEffect(() => {
if (!userId || !settingsReady) return;
refreshPush(userId, language).catch((cause: unknown) => {
console.warn('Could not refresh the push token:', cause instanceof Error ? cause.message : cause);
});
}, [userId, language, settingsReady]);

if (!ready) return null;

// Native form sheets with a grabber on iOS. The web preview shows them as full pages.
Expand Down
Loading
Loading