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 }> {
|
async getServerVersion(): Promise<{ version: string; apk_url: string | null }> {
|
||||||
// /version nie wymaga API key (publiczny endpoint do upgrade discovery).
|
// /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.
|
// Używamy this.request bo czysty fetch bez API key też jest OK, ale endpoint jest auth-free.
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
Modal,
|
Modal,
|
||||||
Platform,
|
Platform,
|
||||||
Pressable,
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -45,12 +46,59 @@ interface Props {
|
||||||
// pokazał "?" zasłaniający duration label).
|
// pokazał "?" zasłaniający duration label).
|
||||||
const FAB_HIDDEN_SCREENS = new Set(['Player']);
|
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) {
|
export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mode, setMode] = useState<'report' | 'messages'>('report');
|
||||||
const [screenshot, setScreenshot] = useState<string | null>(null);
|
const [screenshot, setScreenshot] = useState<string | null>(null);
|
||||||
const [includeScreenshot, setIncludeScreenshot] = useState(true);
|
const [includeScreenshot, setIncludeScreenshot] = useState(true);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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.
|
// Re-render na zmianę current route, żeby FAB pojawiał/znikał per-screen.
|
||||||
const [currentRoute, setCurrentRoute] = useState<string | null>(null);
|
const [currentRoute, setCurrentRoute] = useState<string | null>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -82,8 +130,16 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
||||||
setScreenshot(captured);
|
setScreenshot(captured);
|
||||||
setIncludeScreenshot(captured !== null);
|
setIncludeScreenshot(captured !== null);
|
||||||
setMessage('');
|
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);
|
setOpen(true);
|
||||||
}, []);
|
}, [refreshMine, openMessages]);
|
||||||
|
|
||||||
const submit = useCallback(async () => {
|
const submit = useCallback(async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|
@ -136,6 +192,7 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setScreenshot(null);
|
setScreenshot(null);
|
||||||
|
refreshMine();
|
||||||
Alert.alert('Bug report', 'Sent. Thanks!');
|
Alert.alert('Bug report', 'Sent. Thanks!');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Alert.alert('Bug report', `Failed to send: ${(e as Error).message}`);
|
Alert.alert('Bug report', `Failed to send: ${(e as Error).message}`);
|
||||||
|
|
@ -149,6 +206,11 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
||||||
{!hidden ? (
|
{!hidden ? (
|
||||||
<TouchableOpacity style={styles.fab} onPress={onPress} activeOpacity={0.7}>
|
<TouchableOpacity style={styles.fab} onPress={onPress} activeOpacity={0.7}>
|
||||||
<Text style={styles.fabIcon}>?</Text>
|
<Text style={styles.fabIcon}>?</Text>
|
||||||
|
{unseen > 0 ? (
|
||||||
|
<View style={styles.fabDot}>
|
||||||
|
<Text style={styles.fabDotText}>{unseen > 9 ? '9+' : unseen}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -164,64 +226,113 @@ export function BugReportFAB({ client, appVersion, navRef }: Props) {
|
||||||
>
|
>
|
||||||
<Pressable style={styles.backdropTap} onPress={() => setOpen(false)} />
|
<Pressable style={styles.backdropTap} onPress={() => setOpen(false)} />
|
||||||
<View style={styles.sheet}>
|
<View style={styles.sheet}>
|
||||||
<Text style={styles.title}>Report a bug</Text>
|
<View style={styles.tabRow}>
|
||||||
|
<Pressable
|
||||||
{screenshot ? (
|
style={[styles.tab, mode === 'report' && styles.tabActive]}
|
||||||
<View style={styles.preview}>
|
onPress={() => setMode('report')}
|
||||||
<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>
|
<Text style={[styles.tabText, mode === 'report' && styles.tabTextActive]}>
|
||||||
</TouchableOpacity>
|
Report a bug
|
||||||
<TouchableOpacity
|
</Text>
|
||||||
style={[styles.btn, styles.btnSend]}
|
</Pressable>
|
||||||
onPress={submit}
|
<Pressable
|
||||||
disabled={submitting}
|
style={[styles.tab, mode === 'messages' && styles.tabActive]}
|
||||||
|
onPress={openMessages}
|
||||||
>
|
>
|
||||||
{submitting ? (
|
<Text style={[styles.tabText, mode === 'messages' && styles.tabTextActive]}>
|
||||||
<ActivityIndicator color={theme.fg} />
|
Your messages{myReports.length ? ` (${myReports.length})` : ''}
|
||||||
) : (
|
</Text>
|
||||||
<Text style={styles.btnText}>Send</Text>
|
{unseen > 0 ? <View style={styles.tabDot} /> : null}
|
||||||
)}
|
</Pressable>
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</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>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
@ -334,4 +445,63 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
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 React from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
Animated,
|
Animated,
|
||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
Pressable,
|
Pressable,
|
||||||
|
|
@ -1127,6 +1128,7 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
|
||||||
const t = setTimeout(() => setRevealEmbed(true), 15000);
|
const t = setTimeout(() => setRevealEmbed(true), 15000);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [extractedUrl]);
|
}, [extractedUrl]);
|
||||||
|
const [geoHintShown, setGeoHintShown] = React.useState(false);
|
||||||
|
|
||||||
const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
|
const { markBroken, canMark, busy: markBusy } = useMarkSourceBroken(params);
|
||||||
// WebView main-document 404/410 → strona hostera usunięta (fpo/sxyprn „404
|
// WebView main-document 404/410 → strona hostera usunięta (fpo/sxyprn „404
|
||||||
|
|
@ -1152,6 +1154,24 @@ function EmbedWebViewPlayer({ params }: { params: RouteParams }) {
|
||||||
[url],
|
[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
|
// Stage 0.8: mobile-side hoster resolvery. Mobile IP usera unika Cloudflare
|
||||||
// Turnstile / CAPTCHA gate który blokuje Hetzner VPS — embed page renderuje
|
// 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.
|
// pełny HTML z player config, z którego liczymy direct m3u8/mp4 URL.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue