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;