goon/mobile/src/components/WhatsNewModal.tsx
jtrzupek e618087eae feat(mobile): "What's new" popup after OTA updates
After an OTA bundle is applied, show a one-time popup listing recent changes. The
changelog ships in the bundle (mobile/src/changelog.ts), so it is always in sync with
the code that just arrived. WhatsNewModal compares the newest entry id against the last
one seen (SecureStore); shows unseen entries, marks seen on dismiss, and stays quiet
until the next update adds an entry. First run shows only the newest entry (no history
dump). Mounted over the navigator when signed in.

Each OTA publish should prepend a new entry at the top of CHANGELOG.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:41:54 +02:00

120 lines
3.5 KiB
TypeScript

/**
* "What's new" popup — pojawia się raz po dociągnięciu OTA (bundle ma nowszy changelog
* niż ostatnio widziany). Self-contained: czyta CHANGELOG (bundlowany) + lastSeen ze
* SecureStore. Po zamknięciu zapisuje najnowszy id, więc nie pokazuje się ponownie aż
* do kolejnej aktualizacji. Montowany raz, nad nawigacją (App.tsx).
*/
import React from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import {
CHANGELOG,
NEWEST_CHANGELOG_ID,
unseenEntries,
type ChangelogEntry,
} from '../changelog';
import { getLastSeenChangelog, setLastSeenChangelog } from '../storage';
import { theme } from '../theme';
export function WhatsNewModal() {
const [entries, setEntries] = React.useState<ChangelogEntry[]>([]);
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const lastSeen = await getLastSeenChangelog();
const unseen = unseenEntries(lastSeen);
if (!cancelled && unseen.length > 0) {
setEntries(unseen);
setVisible(true);
}
} catch {
// brak SecureStore / błąd — po prostu nie pokazujemy
}
})();
return () => {
cancelled = true;
};
}, []);
const dismiss = React.useCallback(() => {
setVisible(false);
if (NEWEST_CHANGELOG_ID) setLastSeenChangelog(NEWEST_CHANGELOG_ID).catch(() => {});
}, []);
if (!visible || entries.length === 0) return null;
return (
<Modal visible transparent animationType="fade" onRequestClose={dismiss}>
<View style={styles.backdrop}>
<View style={styles.card}>
<Text style={styles.title}>What's new</Text>
<ScrollView style={styles.scroll} contentContainerStyle={{ paddingBottom: 4 }}>
{entries.map((e) => (
<View key={e.id} style={styles.entry}>
{CHANGELOG.length > 1 ? <Text style={styles.entryDate}>{e.date}</Text> : null}
{e.items.map((it, i) => (
<View key={i} style={styles.itemRow}>
<Text style={styles.bullet}>•</Text>
<Text style={styles.itemText}>{it}</Text>
</View>
))}
</View>
))}
</ScrollView>
<Pressable style={styles.btn} onPress={dismiss}>
<Text style={styles.btnText}>Got it</Text>
</Pressable>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
card: {
width: '100%',
maxHeight: '80%',
backgroundColor: theme.bgElevated,
borderRadius: 16,
borderColor: theme.border,
borderWidth: 1,
padding: 20,
},
title: {
color: theme.fg,
fontSize: 20,
fontWeight: '800',
marginBottom: 14,
},
scroll: { flexGrow: 0 },
entry: { marginBottom: 14 },
entryDate: {
color: theme.accent,
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 8,
},
itemRow: { flexDirection: 'row', gap: 8, marginBottom: 8 },
bullet: { color: theme.accent, fontSize: 15, lineHeight: 21 },
itemText: { color: theme.fg, fontSize: 15, lineHeight: 21, flex: 1 },
btn: {
marginTop: 8,
backgroundColor: theme.accent,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
btnText: { color: theme.fg, fontSize: 15, fontWeight: '700' },
});