diff --git a/mobile/src/api.ts b/mobile/src/api.ts index bd0b1c4..f4675a5 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -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. diff --git a/mobile/src/components/BugReportFAB.tsx b/mobile/src/components/BugReportFAB.tsx index d4e11cf..821ddf7 100644 --- a/mobile/src/components/BugReportFAB.tsx +++ b/mobile/src/components/BugReportFAB.tsx @@ -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(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([]); + const [unseen, setUnseen] = useState(0); + + const refreshMine = useCallback(async (): Promise => { + 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(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 ? ( ? + {unseen > 0 ? ( + + {unseen > 9 ? '9+' : unseen} + + ) : null} ) : null} @@ -164,64 +226,113 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) { > setOpen(false)} /> - Report a bug - - {screenshot ? ( - - - - Attach screenshot - - - - ) : ( - - Screenshot unavailable (capture failed). Sending text only. - - )} - - - - - setOpen(false)} - disabled={submitting} + + setMode('report')} > - Cancel - - + Report a bug + + + - {submitting ? ( - - ) : ( - Send - )} - + + Your messages{myReports.length ? ` (${myReports.length})` : ''} + + {unseen > 0 ? : null} + + + {mode === 'report' ? ( + <> + {screenshot ? ( + + + + Attach screenshot + + + + ) : ( + + Screenshot unavailable (capture failed). Sending text only. + + )} + + + + + setOpen(false)} + disabled={submitting} + > + Cancel + + + {submitting ? ( + + ) : ( + Send + )} + + + + ) : ( + + {myReports.length === 0 ? ( + + No messages yet. Reports you send show up here with any reply. + + ) : ( + myReports.map((r) => ( + + + {r.message} + + {r.response ? ( + + Reply from the team + {r.response} + + ) : ( + No reply yet + )} + + )) + )} + setOpen(false)} + > + Close + + + )} @@ -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' }, }); diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index 60b0cbd..cffdb7c 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -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.