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:
parent
d1f2f035b0
commit
a00acdddfb
3 changed files with 265 additions and 56 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,8 +226,28 @@ 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>
|
||||
<View style={styles.tabRow}>
|
||||
<Pressable
|
||||
style={[styles.tab, mode === 'report' && styles.tabActive]}
|
||||
onPress={() => setMode('report')}
|
||||
>
|
||||
<Text style={[styles.tabText, mode === 'report' && styles.tabTextActive]}>
|
||||
Report a bug
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.tab, mode === 'messages' && styles.tabActive]}
|
||||
onPress={openMessages}
|
||||
>
|
||||
<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
|
||||
|
|
@ -179,10 +261,7 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
|||
value={includeScreenshot}
|
||||
onValueChange={setIncludeScreenshot}
|
||||
thumbColor={includeScreenshot ? theme.accent : theme.muted}
|
||||
trackColor={{
|
||||
true: theme.accentDeep,
|
||||
false: theme.border,
|
||||
}}
|
||||
trackColor={{ true: theme.accentDeep, false: theme.border }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -222,6 +301,38 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
|||
)}
|
||||
</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' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue