/** * "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 { NEWEST_CHANGELOG_ID, unseenEntries, type ChangelogEntry, } from '../changelog'; import { getLastSeenChangelog, getOnboardingSeen, setLastSeenChangelog } from '../storage'; import { theme } from '../theme'; export function WhatsNewModal() { const [entries, setEntries] = React.useState([]); const [visible, setVisible] = React.useState(false); React.useEffect(() => { let cancelled = false; (async () => { try { // Nowy user (jeszcze nie przeszedł tutoriala) → tutorial ma pierwszeństwo, // nie wyskakuj changelogiem. Onboarding po sobie oznaczy changelog widziany. const onboarded = await getOnboardingSeen(); if (!onboarded) return; 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(() => {}); }, []); // Nagłówek miesiąca RAZ na grupę, nie przy każdym wpisie. Wcześniej 17 sierpniowych // wpisów drukowało „AUGUST 2026" 17 razy (zgłoszenie 2026-08-18). Wpisy są już // newest-first, więc kolejne z tym samym `date` po prostu doklejamy do bieżącej grupy. const months = React.useMemo(() => { const out: { date: string; items: string[] }[] = []; for (const e of entries) { const last = out[out.length - 1]; if (last && last.date === e.date) last.items.push(...e.items); else out.push({ date: e.date, items: [...e.items] }); } return out; }, [entries]); if (!visible || entries.length === 0) return null; return ( What's new {months.map((m) => ( {m.date} {m.items.map((it, i) => ( • {it} ))} ))} Got it ); } 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 }, month: { marginBottom: 16 }, entryDate: { color: theme.accentText, fontSize: 12, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8, }, itemRow: { flexDirection: 'row', gap: 8, marginBottom: 8 }, bullet: { color: theme.accentText, 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' }, });