feat(mobile): "Your messages" inbox on bug FAB + geo-block playback hint

Bug FAB now has two tabs: "Report a bug" (existing) and "Your messages", which lists
this device's reports with any admin reply in a highlighted box. A badge dot on the FAB
shows unread replies; opening the tab marks them seen. Polls every 90s and on open.

PlayerScreen: when the WebView fallback (residential IP) cannot extract a stream within
25s and there is no 404/410, show a one-time hint that the source may be blocked in the
user's region or by their ISP (try another source or a VPN) - so a geo/network block on
the user's side does not read as a broken app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-12 11:35:44 +02:00
parent d1f2f035b0
commit a00acdddfb
3 changed files with 265 additions and 56 deletions

View file

@ -467,6 +467,25 @@ export class GoonClient {
});
}
async listMyBugReports(): Promise<{
items: {
id: string;
created_at: string;
screen_name: string | null;
message: string;
response: string | null;
responded_at: string | null;
response_seen: boolean;
}[];
unseen: number;
}> {
return this.request('/bug-reports/mine');
}
async markBugRepliesSeen(): Promise<{ marked: number }> {
return this.request('/bug-reports/mine/seen', { method: 'POST' });
}
async getServerVersion(): Promise<{ version: string; apk_url: string | null }> {
// /version nie wymaga API key (publiczny endpoint do upgrade discovery).
// Używamy this.request bo czysty fetch bez API key też jest OK, ale endpoint jest auth-free.

View file

@ -22,6 +22,7 @@ import {
Modal,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
@ -45,12 +46,59 @@ interface Props {
// pokazał "?" zasłaniający duration label).
const FAB_HIDDEN_SCREENS = new Set(['Player']);
type MyReport = {
id: string;
created_at: string;
screen_name: string | null;
message: string;
response: string | null;
responded_at: string | null;
response_seen: boolean;
};
export function BugReportFAB({ client, appVersion, navRef }: Props) {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<'report' | 'messages'>('report');
const [screenshot, setScreenshot] = useState<string | null>(null);
const [includeScreenshot, setIncludeScreenshot] = useState(true);
const [message, setMessage] = useState('');
const [submitting, setSubmitting] = useState(false);
// "Your messages": własne zgłoszenia + odpowiedzi admina. `unseen` → kropka na FAB.
const [myReports, setMyReports] = useState<MyReport[]>([]);
const [unseen, setUnseen] = useState(0);
const refreshMine = useCallback(async (): Promise<number> => {
if (!client) return 0;
try {
const r = await client.listMyBugReports();
setMyReports(r.items);
setUnseen(r.unseen);
return r.unseen;
} catch {
// offline / brak — kropki po prostu nie pokazujemy
return 0;
}
}, [client]);
// Poll na starcie + co 90s, żeby kropka pojawiła się po odpowiedzi admina.
React.useEffect(() => {
refreshMine();
const t = setInterval(refreshMine, 90_000);
return () => clearInterval(t);
}, [refreshMine]);
// Wejście w 'messages' = przeczytane → gasimy kropkę (lokalnie + na backendzie).
const openMessages = useCallback(async () => {
setMode('messages');
if (unseen > 0 && client) {
setUnseen(0);
try {
await client.markBugRepliesSeen();
} catch {
// brak sieci — kropka wróci przy następnym refreshMine
}
}
}, [unseen, client]);
// Re-render na zmianę current route, żeby FAB pojawiał/znikał per-screen.
const [currentRoute, setCurrentRoute] = useState<string | null>(null);
React.useEffect(() => {
@ -82,8 +130,16 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
setScreenshot(captured);
setIncludeScreenshot(captured !== null);
setMessage('');
// Świeży stan wiadomości; jeśli jest nieprzeczytana odpowiedź, otwórz od razu
// na 'messages' (user kliknął FAB z kropką, chce ją zobaczyć).
const n = await refreshMine();
if (n > 0) {
await openMessages();
} else {
setMode('report');
}
setOpen(true);
}, []);
}, [refreshMine, openMessages]);
const submit = useCallback(async () => {
if (!client) {
@ -136,6 +192,7 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
setOpen(false);
setMessage('');
setScreenshot(null);
refreshMine();
Alert.alert('Bug report', 'Sent. Thanks!');
} catch (e) {
Alert.alert('Bug report', `Failed to send: ${(e as Error).message}`);
@ -149,6 +206,11 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
{!hidden ? (
<TouchableOpacity style={styles.fab} onPress={onPress} activeOpacity={0.7}>
<Text style={styles.fabIcon}>?</Text>
{unseen > 0 ? (
<View style={styles.fabDot}>
<Text style={styles.fabDotText}>{unseen > 9 ? '9+' : unseen}</Text>
</View>
) : null}
</TouchableOpacity>
) : null}
@ -164,64 +226,113 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
>
<Pressable style={styles.backdropTap} onPress={() => setOpen(false)} />
<View style={styles.sheet}>
<Text style={styles.title}>Report a bug</Text>
{screenshot ? (
<View style={styles.preview}>
<Image
source={{ uri: `data:image/jpeg;base64,${screenshot}` }}
style={styles.previewImg}
resizeMode="contain"
/>
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Attach screenshot</Text>
<Switch
value={includeScreenshot}
onValueChange={setIncludeScreenshot}
thumbColor={includeScreenshot ? theme.accent : theme.muted}
trackColor={{
true: theme.accentDeep,
false: theme.border,
}}
/>
</View>
</View>
) : (
<Text style={styles.noScreenshot}>
Screenshot unavailable (capture failed). Sending text only.
</Text>
)}
<TextInput
style={styles.input}
value={message}
onChangeText={setMessage}
placeholder="What's wrong? Scene title / what you're trying to do / what you saw"
placeholderTextColor={theme.mutedDim}
multiline
autoFocus
/>
<View style={styles.btnRow}>
<TouchableOpacity
style={[styles.btn, styles.btnCancel]}
onPress={() => setOpen(false)}
disabled={submitting}
<View style={styles.tabRow}>
<Pressable
style={[styles.tab, mode === 'report' && styles.tabActive]}
onPress={() => setMode('report')}
>
<Text style={styles.btnText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, styles.btnSend]}
onPress={submit}
disabled={submitting}
<Text style={[styles.tabText, mode === 'report' && styles.tabTextActive]}>
Report a bug
</Text>
</Pressable>
<Pressable
style={[styles.tab, mode === 'messages' && styles.tabActive]}
onPress={openMessages}
>
{submitting ? (
<ActivityIndicator color={theme.fg} />
) : (
<Text style={styles.btnText}>Send</Text>
)}
</TouchableOpacity>
<Text style={[styles.tabText, mode === 'messages' && styles.tabTextActive]}>
Your messages{myReports.length ? ` (${myReports.length})` : ''}
</Text>
{unseen > 0 ? <View style={styles.tabDot} /> : null}
</Pressable>
</View>
{mode === 'report' ? (
<>
{screenshot ? (
<View style={styles.preview}>
<Image
source={{ uri: `data:image/jpeg;base64,${screenshot}` }}
style={styles.previewImg}
resizeMode="contain"
/>
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Attach screenshot</Text>
<Switch
value={includeScreenshot}
onValueChange={setIncludeScreenshot}
thumbColor={includeScreenshot ? theme.accent : theme.muted}
trackColor={{ true: theme.accentDeep, false: theme.border }}
/>
</View>
</View>
) : (
<Text style={styles.noScreenshot}>
Screenshot unavailable (capture failed). Sending text only.
</Text>
)}
<TextInput
style={styles.input}
value={message}
onChangeText={setMessage}
placeholder="What's wrong? Scene title / what you're trying to do / what you saw"
placeholderTextColor={theme.mutedDim}
multiline
autoFocus
/>
<View style={styles.btnRow}>
<TouchableOpacity
style={[styles.btn, styles.btnCancel]}
onPress={() => setOpen(false)}
disabled={submitting}
>
<Text style={styles.btnText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.btn, styles.btnSend]}
onPress={submit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator color={theme.fg} />
) : (
<Text style={styles.btnText}>Send</Text>
)}
</TouchableOpacity>
</View>
</>
) : (
<ScrollView style={styles.msgList} contentContainerStyle={{ paddingBottom: 8 }}>
{myReports.length === 0 ? (
<Text style={styles.noScreenshot}>
No messages yet. Reports you send show up here with any reply.
</Text>
) : (
myReports.map((r) => (
<View key={r.id} style={styles.msgItem}>
<Text style={styles.msgYou} numberOfLines={4}>
{r.message}
</Text>
{r.response ? (
<View style={styles.replyBox}>
<Text style={styles.replyLabel}>Reply from the team</Text>
<Text style={styles.replyText}>{r.response}</Text>
</View>
) : (
<Text style={styles.noReply}>No reply yet</Text>
)}
</View>
))
)}
<TouchableOpacity
style={[styles.btn, styles.btnCancel, { marginTop: 12 }]}
onPress={() => setOpen(false)}
>
<Text style={styles.btnText}>Close</Text>
</TouchableOpacity>
</ScrollView>
)}
</View>
</KeyboardAvoidingView>
</Modal>
@ -334,4 +445,63 @@ const styles = StyleSheet.create({
fontSize: 15,
fontWeight: '600',
},
fabDot: {
position: 'absolute',
top: -2,
right: -2,
minWidth: 18,
height: 18,
paddingHorizontal: 4,
borderRadius: 9,
backgroundColor: theme.accent,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1.5,
borderColor: theme.bg,
},
fabDotText: { color: theme.fg, fontSize: 10, fontWeight: '800' },
tabRow: { flexDirection: 'row', gap: 8, marginBottom: 14 },
tab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 9,
borderRadius: 8,
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
},
tabActive: { borderColor: theme.accent, backgroundColor: theme.bg },
tabText: { color: theme.muted, fontWeight: '600', fontSize: 14 },
tabTextActive: { color: theme.fg },
tabDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: theme.accent },
msgList: { maxHeight: 380 },
msgItem: {
backgroundColor: theme.card,
borderColor: theme.border,
borderWidth: 1,
borderRadius: 10,
padding: 12,
marginBottom: 10,
},
msgYou: { color: theme.fg, fontSize: 14, lineHeight: 19 },
replyBox: {
marginTop: 10,
borderLeftWidth: 3,
borderLeftColor: theme.accent,
paddingLeft: 10,
paddingVertical: 4,
},
replyLabel: {
color: theme.accent,
fontSize: 11,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 3,
},
replyText: { color: theme.fg, fontSize: 14, lineHeight: 19 },
noReply: { color: theme.mutedDim, fontSize: 12, marginTop: 8, fontStyle: 'italic' },
});

View file

@ -9,6 +9,7 @@ import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
import React from 'react';
import {
ActivityIndicator,
Alert,
Animated,
LayoutChangeEvent,
Pressable,
@ -1127,6 +1128,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
const t = setTimeout(() => setRevealEmbed(true), 15000);
return () => clearTimeout(t);
}, [extractedUrl]);
const [geoHintShown, setGeoHintShown] = React.useState(false);
const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
// WebView main-document 404/410 → strona hostera usunięta (fpo/sxyprn „404
@ -1152,6 +1154,24 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
[url],
);
// Geo/ISP-block monit: jeśli po 25s WebView (residential IP usera) nie wyciągnął
// wideo i nie ma 404/410, to najczęściej źródło jest blokowane w regionie/sieci
// usera (porndoe itp. — sprawdzone: gra z innego residential IP). Jednorazowy monit
// "to pewnie po Twojej stronie", żeby user nie myślał że apka jest zepsuta.
React.useEffect(() => {
if (extractedUrl || httpDead || geoHintShown || resolveStatus === 'pending') return;
const t = setTimeout(() => {
setGeoHintShown(true);
Alert.alert(
'Source not loading',
'This source may be blocked in your region or by your network/ISP. ' +
'It usually works on other connections — try another source on the scene, or a VPN.',
[{ text: 'OK' }],
);
}, 25000);
return () => clearTimeout(t);
}, [extractedUrl, httpDead, geoHintShown, resolveStatus]);
// Stage 0.8: mobile-side hoster resolvery. Mobile IP usera unika Cloudflare
// Turnstile / CAPTCHA gate który blokuje Hetzner VPS — embed page renderuje
// pełny HTML z player config, z którego liczymy direct m3u8/mp4 URL.