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>
This commit is contained in:
jtrzupek 2026-06-12 11:41:54 +02:00
parent a00acdddfb
commit e618087eae
4 changed files with 175 additions and 0 deletions

View file

@ -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();
}}
/>
<WhatsNewModal />
</SceneActionsProvider>
</ClientProvider>
) : (

41
mobile/src/changelog.ts Normal file
View file

@ -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);
}

View file

@ -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
* 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' },
});

View file

@ -38,6 +38,18 @@ export async function markLegacyAdopted(): Promise<void> {
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<string | null> {
return SecureStore.getItemAsync(CHANGELOG_SEEN_KEY);
}
export async function setLastSeenChangelog(id: string): Promise<void> {
await SecureStore.setItemAsync(CHANGELOG_SEEN_KEY, id);
}
export interface Credentials {
baseUrl: string;
apiKey: string;