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:
parent
a00acdddfb
commit
e618087eae
4 changed files with 175 additions and 0 deletions
|
|
@ -24,6 +24,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
import { GoonClient } from './src/api';
|
import { GoonClient } from './src/api';
|
||||||
import { ClientProvider } from './src/ClientContext';
|
import { ClientProvider } from './src/ClientContext';
|
||||||
import { SceneActionsProvider } from './src/SceneActionsContext';
|
import { SceneActionsProvider } from './src/SceneActionsContext';
|
||||||
|
import { WhatsNewModal } from './src/components/WhatsNewModal';
|
||||||
import { ErrorBoundary } from './src/ErrorBoundary';
|
import { ErrorBoundary } from './src/ErrorBoundary';
|
||||||
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
import { isAccepted as isAgeGateAccepted } from './src/lib/agegate';
|
||||||
import { APP_VERSION } from './src/lib/appVersion';
|
import { APP_VERSION } from './src/lib/appVersion';
|
||||||
|
|
@ -363,6 +364,7 @@ export default function App() {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<WhatsNewModal />
|
||||||
</SceneActionsProvider>
|
</SceneActionsProvider>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
41
mobile/src/changelog.ts
Normal file
41
mobile/src/changelog.ts
Normal 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);
|
||||||
|
}
|
||||||
120
mobile/src/components/WhatsNewModal.tsx
Normal file
120
mobile/src/components/WhatsNewModal.tsx
Normal 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 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' },
|
||||||
|
});
|
||||||
|
|
@ -38,6 +38,18 @@ export async function markLegacyAdopted(): Promise<void> {
|
||||||
await SecureStore.setItemAsync(LEGACY_ADOPTED_KEY, '1');
|
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 {
|
export interface Credentials {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue