diff --git a/mobile/App.tsx b/mobile/App.tsx index f32ba12..2b19ffe 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -24,6 +24,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import { GoonClient } from './src/api'; import { ClientProvider } from './src/ClientContext'; import { SceneActionsProvider } from './src/SceneActionsContext'; +import { WhatsNewModal } from './src/components/WhatsNewModal'; import { ErrorBoundary } from './src/ErrorBoundary'; import { isAccepted as isAgeGateAccepted } from './src/lib/agegate'; import { APP_VERSION } from './src/lib/appVersion'; @@ -363,6 +364,7 @@ export default function App() { queryClient.clear(); }} /> + ) : ( diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts new file mode 100644 index 0000000..3dcfc23 --- /dev/null +++ b/mobile/src/changelog.ts @@ -0,0 +1,41 @@ +/** + * Changelog bundlowany z apką → ZAWSZE zgodny z tym, co przyszło OTA (te same zmiany + * lecą w tym samym bundlu). WhatsNewModal pokazuje wpisy nowsze niż ostatnio widziany + * (`lastSeenChangelogId` w SecureStore) i oznacza najnowszy jako przeczytany. + * + * Przy każdym publish OTA: DOPISZ nowy wpis NA GÓRZE (newest-first), z nowym `id`. + * Treść user-facing (bez wewnętrznego żargonu) — to widzi użytkownik. + */ +export type ChangelogEntry = { + /** Stabilny, rosnący identyfikator (np. data + seq). Newest-first w CHANGELOG. */ + id: string; + /** Krótki, ludzki tytuł daty/wersji pokazywany w nagłówku wpisu. */ + date: string; + /** Punkty zmian — krótkie, user-facing. */ + items: string[]; +}; + +export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-06-12', + date: 'June 2026', + items: [ + 'Favorites now has a Scenes tab — every scene you heart shows up there.', + 'Your bug reports can get replies: tap the ? button, then "Your messages".', + 'More reliable playback and lower data use on several sites.', + 'Clearer message when a source is blocked in your region or by your network.', + ], + }, +]; + +export const NEWEST_CHANGELOG_ID = CHANGELOG.length ? CHANGELOG[0].id : null; + +/** Wpisy nowsze niż `lastSeenId`. lastSeen=null (pierwsze uruchomienie) → tylko najnowszy + * (nie zrzucamy całej historii). lastSeen == najnowszy → pusto (nic do pokazania). */ +export function unseenEntries(lastSeenId: string | null): ChangelogEntry[] { + if (!CHANGELOG.length) return []; + if (!lastSeenId) return [CHANGELOG[0]]; + const idx = CHANGELOG.findIndex((e) => e.id === lastSeenId); + if (idx === -1) return [CHANGELOG[0]]; // nieznany marker → pokaż tylko najnowszy + return CHANGELOG.slice(0, idx); +} diff --git a/mobile/src/components/WhatsNewModal.tsx b/mobile/src/components/WhatsNewModal.tsx new file mode 100644 index 0000000..d011425 --- /dev/null +++ b/mobile/src/components/WhatsNewModal.tsx @@ -0,0 +1,120 @@ +/** + * "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([]); + 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 ( + + + + What's new + + {entries.map((e) => ( + + {CHANGELOG.length > 1 ? {e.date} : null} + {e.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 }, + 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' }, +}); diff --git a/mobile/src/storage.ts b/mobile/src/storage.ts index 686c0db..0577757 100644 --- a/mobile/src/storage.ts +++ b/mobile/src/storage.ts @@ -38,6 +38,18 @@ export async function markLegacyAdopted(): Promise { await SecureStore.setItemAsync(LEGACY_ADOPTED_KEY, '1'); } +// "What's new" — ostatnio widziany wpis changelogu. Po dociągnięciu OTA bundle ma +// nowszy changelog → WhatsNewModal pokazuje różnicę i zapisuje najnowszy id. +const CHANGELOG_SEEN_KEY = 'goon.changelog_seen_id'; + +export async function getLastSeenChangelog(): Promise { + return SecureStore.getItemAsync(CHANGELOG_SEEN_KEY); +} + +export async function setLastSeenChangelog(id: string): Promise { + await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id); +} + export interface Credentials { baseUrl: string; apiKey: string;