9308fded3e
Original task: Build a downloadable APK so you can install the app on any Android phone.
What was done:
- eas.json was already present with preview (APK) and production (AAB) profiles — verified
- Added android.package "fr.gyozamancave.postizmobile" to app.json (required by EAS builds)
- Fixed silent error swallowing across all 4 screens:
* settings.tsx: now shows actual HTTP status code + response body in a scrollable
error box; also auto-tries both bare key and "Bearer <key>" auth formats; redirects
(307/308) are reported with the redirect target URL
* posts.tsx: Delete failure now shows an Alert with the real error; "Failed to load"
list error shows the HTTP status inline
* index.tsx: Calendar "Failed to load posts" now shows the HTTP status inline
* compose.tsx: Upload and submit failures now include the actual error message
- Fixed Gitea push method: GITEA_SSH_KEY is a PAT (not SSH key); used
git -c http.extraHeader=Authorization: token ... to authenticate and force-pushed
all changes to homegit.gyozamancave.fr/billisdead/Postiz-android.git
Deviations:
- APK not yet built: user has no Expo account (confirmed by user). EAS build requires
a free expo.dev account + interactive eas login. Proposed as follow-up task #7.
- Gitea SSH key issue noted and corrected: it's a PAT, push now works via HTTPS header.
Obsolete follow-up #6 may be retracted since push now works.
441 lines
13 KiB
TypeScript
441 lines
13 KiB
TypeScript
import { Feather } from "@expo/vector-icons";
|
|
import axios from "axios";
|
|
import * as Haptics from "expo-haptics";
|
|
import React, { useEffect, useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Alert,
|
|
Platform,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
View,
|
|
} from "react-native";
|
|
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { usePostiz } from "@/context/PostizContext";
|
|
import { useColors } from "@/hooks/useColors";
|
|
|
|
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/public/v1";
|
|
|
|
function extractAxiosError(err: unknown): string {
|
|
if (axios.isAxiosError(err)) {
|
|
const status = err.response?.status;
|
|
const data = err.response?.data;
|
|
if (data) {
|
|
const body =
|
|
typeof data === "string"
|
|
? data.slice(0, 200)
|
|
: (data?.message ?? data?.error ?? JSON.stringify(data)).toString().slice(0, 200);
|
|
return status ? `HTTP ${status}: ${body}` : body;
|
|
}
|
|
if (status) return `HTTP ${status} — ${err.message}`;
|
|
if (err.code === "ECONNABORTED") return "Request timed out (10s). Check that the URL is reachable.";
|
|
if (err.message) return err.message;
|
|
}
|
|
if (err instanceof Error) return err.message;
|
|
return "Unknown error";
|
|
}
|
|
|
|
export default function SettingsScreen() {
|
|
const colors = useColors();
|
|
const insets = useSafeAreaInsets();
|
|
const { apiKey, baseUrl, isConfigured, saveSettings, clearSettings } = usePostiz();
|
|
|
|
const [inputKey, setInputKey] = useState(apiKey);
|
|
const [inputUrl, setInputUrl] = useState(baseUrl || DEFAULT_BASE_URL);
|
|
const [showKey, setShowKey] = useState(false);
|
|
const [validating, setValidating] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [validationStatus, setValidationStatus] = useState<"idle" | "ok" | "error">("idle");
|
|
const [errorDetail, setErrorDetail] = useState<string>("");
|
|
|
|
useEffect(() => {
|
|
setInputKey(apiKey);
|
|
setInputUrl(baseUrl || DEFAULT_BASE_URL);
|
|
}, [apiKey, baseUrl]);
|
|
|
|
const handleValidate = async () => {
|
|
if (!inputKey.trim() || !inputUrl.trim()) {
|
|
Alert.alert("Missing fields", "Please enter both API key and base URL.");
|
|
return;
|
|
}
|
|
setValidating(true);
|
|
setValidationStatus("idle");
|
|
setErrorDetail("");
|
|
const cleanUrl = inputUrl.trim().replace(/\/$/, "");
|
|
|
|
const authVariants = [
|
|
inputKey.trim(),
|
|
`Bearer ${inputKey.trim()}`,
|
|
];
|
|
|
|
let lastError: string = "";
|
|
|
|
for (const authHeader of authVariants) {
|
|
try {
|
|
await axios.get(`${cleanUrl}/integrations`, {
|
|
headers: { Authorization: authHeader },
|
|
timeout: 10000,
|
|
maxRedirects: 0,
|
|
});
|
|
setValidationStatus("ok");
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
setValidating(false);
|
|
return;
|
|
} catch (err: unknown) {
|
|
if (axios.isAxiosError(err)) {
|
|
const status = err.response?.status;
|
|
if (status === 307 || status === 301 || status === 302 || status === 308) {
|
|
const location = err.response?.headers?.location ?? "unknown";
|
|
lastError = `HTTP ${status} redirect → ${location}. The API rejected the request and redirected to login. Check the Authorization header format or the base URL.`;
|
|
continue;
|
|
}
|
|
if (status === 401 || status === 403) {
|
|
lastError = `HTTP ${status}: Invalid or expired API key.`;
|
|
continue;
|
|
}
|
|
}
|
|
lastError = extractAxiosError(err);
|
|
}
|
|
}
|
|
|
|
setErrorDetail(lastError);
|
|
setValidationStatus("error");
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
|
setValidating(false);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!inputKey.trim() || !inputUrl.trim()) {
|
|
Alert.alert("Missing fields", "Please enter both API key and base URL.");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await saveSettings(inputKey.trim(), inputUrl.trim().replace(/\/$/, ""));
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
Alert.alert("Saved", "Settings saved successfully.");
|
|
} catch (err: unknown) {
|
|
Alert.alert("Error", `Failed to save settings.\n${extractAxiosError(err)}`);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleClear = () => {
|
|
Alert.alert(
|
|
"Disconnect",
|
|
"Remove your API key and disconnect from Postiz?",
|
|
[
|
|
{ text: "Cancel", style: "cancel" },
|
|
{
|
|
text: "Disconnect",
|
|
style: "destructive",
|
|
onPress: async () => {
|
|
await clearSettings();
|
|
setInputKey("");
|
|
setInputUrl(DEFAULT_BASE_URL);
|
|
setValidationStatus("idle");
|
|
setErrorDetail("");
|
|
},
|
|
},
|
|
]
|
|
);
|
|
};
|
|
|
|
return (
|
|
<KeyboardAwareScrollView
|
|
style={{ flex: 1, backgroundColor: colors.background }}
|
|
contentContainerStyle={[
|
|
styles.container,
|
|
{
|
|
paddingTop: Platform.OS === "web" ? 67 : 24,
|
|
paddingBottom: Platform.OS === "web" ? 100 : insets.bottom + 40,
|
|
},
|
|
]}
|
|
bottomOffset={60}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{!isConfigured && (
|
|
<View style={[styles.banner, { backgroundColor: colors.primary + "18", borderColor: colors.primary + "40" }]}>
|
|
<Feather name="info" size={16} color={colors.primary} />
|
|
<Text style={[styles.bannerText, { color: colors.primary }]}>
|
|
Connect to your Postiz instance to get started
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{isConfigured && (
|
|
<View style={[styles.connectedBadge, { backgroundColor: colors.success + "18", borderColor: colors.success + "40" }]}>
|
|
<Feather name="check-circle" size={14} color={colors.success} />
|
|
<Text style={[styles.connectedText, { color: colors.success }]}>
|
|
Connected to Postiz
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.section}>
|
|
<Text style={[styles.label, { color: colors.mutedForeground }]}>BASE URL</Text>
|
|
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
|
<Feather name="globe" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
|
|
<TextInput
|
|
style={[styles.input, { color: colors.foreground }]}
|
|
placeholder="https://postiz.example.com/public/v1"
|
|
placeholderTextColor={colors.mutedForeground}
|
|
value={inputUrl}
|
|
onChangeText={(t) => { setInputUrl(t); setValidationStatus("idle"); setErrorDetail(""); }}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
keyboardType="url"
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.section}>
|
|
<Text style={[styles.label, { color: colors.mutedForeground }]}>API KEY</Text>
|
|
<View style={[styles.inputWrap, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
|
<Feather name="key" size={16} color={colors.mutedForeground} style={styles.inputIcon} />
|
|
<TextInput
|
|
style={[styles.input, { color: colors.foreground }]}
|
|
placeholder="Enter your API key"
|
|
placeholderTextColor={colors.mutedForeground}
|
|
value={inputKey}
|
|
onChangeText={(t) => { setInputKey(t); setValidationStatus("idle"); setErrorDetail(""); }}
|
|
secureTextEntry={!showKey}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
<TouchableOpacity onPress={() => setShowKey((v) => !v)} activeOpacity={0.7}>
|
|
<Feather name={showKey ? "eye-off" : "eye"} size={16} color={colors.mutedForeground} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{validationStatus === "ok" && (
|
|
<View style={styles.validationRow}>
|
|
<Feather name="check-circle" size={13} color={colors.success} />
|
|
<Text style={[styles.validationText, { color: colors.success }]}>
|
|
Connection successful
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{validationStatus === "error" && (
|
|
<View style={[styles.errorBox, { backgroundColor: colors.error + "12", borderColor: colors.error + "30" }]}>
|
|
<View style={styles.errorHeader}>
|
|
<Feather name="x-circle" size={13} color={colors.error} />
|
|
<Text style={[styles.errorTitle, { color: colors.error }]}>
|
|
Could not connect
|
|
</Text>
|
|
</View>
|
|
{!!errorDetail && (
|
|
<ScrollView style={styles.errorScroll} nestedScrollEnabled>
|
|
<Text style={[styles.errorDetail, { color: colors.error }]} selectable>
|
|
{errorDetail}
|
|
</Text>
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
onPress={handleValidate}
|
|
activeOpacity={0.8}
|
|
disabled={validating}
|
|
style={[styles.validateBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
|
|
>
|
|
{validating ? (
|
|
<ActivityIndicator color={colors.primary} size="small" />
|
|
) : (
|
|
<>
|
|
<Feather name="wifi" size={15} color={colors.primary} />
|
|
<Text style={[styles.validateText, { color: colors.primary }]}>
|
|
Test Connection
|
|
</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
onPress={handleSave}
|
|
activeOpacity={0.85}
|
|
disabled={saving}
|
|
style={[styles.saveBtn, { backgroundColor: saving ? colors.muted : colors.primary }]}
|
|
>
|
|
{saving ? (
|
|
<ActivityIndicator color={colors.primaryForeground} size="small" />
|
|
) : (
|
|
<>
|
|
<Feather name="save" size={15} color={colors.primaryForeground} />
|
|
<Text style={[styles.saveText, { color: colors.primaryForeground }]}>
|
|
Save Settings
|
|
</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
{isConfigured && (
|
|
<TouchableOpacity
|
|
onPress={handleClear}
|
|
activeOpacity={0.8}
|
|
style={[styles.clearBtn, { borderColor: colors.destructive + "60" }]}
|
|
>
|
|
<Feather name="log-out" size={14} color={colors.destructive} />
|
|
<Text style={[styles.clearText, { color: colors.destructive }]}>Disconnect</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
<View style={styles.footer}>
|
|
<Text style={[styles.footerText, { color: colors.mutedForeground }]}>
|
|
Your API key is stored securely on this device and never transmitted to third parties.
|
|
</Text>
|
|
</View>
|
|
</KeyboardAwareScrollView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
paddingHorizontal: 20,
|
|
gap: 16,
|
|
},
|
|
banner: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 10,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 12,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
},
|
|
bannerText: {
|
|
fontSize: 13,
|
|
fontFamily: "Inter_500Medium",
|
|
flex: 1,
|
|
},
|
|
connectedBadge: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 8,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
alignSelf: "flex-start",
|
|
},
|
|
connectedText: {
|
|
fontSize: 12,
|
|
fontFamily: "Inter_600SemiBold",
|
|
},
|
|
section: {
|
|
gap: 8,
|
|
},
|
|
label: {
|
|
fontSize: 11,
|
|
fontFamily: "Inter_600SemiBold",
|
|
letterSpacing: 0.8,
|
|
marginLeft: 2,
|
|
},
|
|
inputWrap: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 12,
|
|
gap: 10,
|
|
},
|
|
inputIcon: {
|
|
flexShrink: 0,
|
|
},
|
|
input: {
|
|
flex: 1,
|
|
fontSize: 14,
|
|
fontFamily: "Inter_400Regular",
|
|
},
|
|
validationRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
marginLeft: 2,
|
|
},
|
|
validationText: {
|
|
fontSize: 12,
|
|
fontFamily: "Inter_400Regular",
|
|
},
|
|
errorBox: {
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
padding: 12,
|
|
gap: 6,
|
|
},
|
|
errorHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
},
|
|
errorTitle: {
|
|
fontSize: 12,
|
|
fontFamily: "Inter_600SemiBold",
|
|
},
|
|
errorScroll: {
|
|
maxHeight: 80,
|
|
},
|
|
errorDetail: {
|
|
fontSize: 11,
|
|
fontFamily: "Inter_400Regular",
|
|
lineHeight: 16,
|
|
},
|
|
validateBtn: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
paddingVertical: 12,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
},
|
|
validateText: {
|
|
fontSize: 14,
|
|
fontFamily: "Inter_600SemiBold",
|
|
},
|
|
saveBtn: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
paddingVertical: 14,
|
|
borderRadius: 14,
|
|
},
|
|
saveText: {
|
|
fontSize: 15,
|
|
fontFamily: "Inter_600SemiBold",
|
|
},
|
|
clearBtn: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
paddingVertical: 12,
|
|
borderRadius: 12,
|
|
borderWidth: 1,
|
|
},
|
|
clearText: {
|
|
fontSize: 14,
|
|
fontFamily: "Inter_500Medium",
|
|
},
|
|
footer: {
|
|
marginTop: 8,
|
|
},
|
|
footerText: {
|
|
fontSize: 12,
|
|
fontFamily: "Inter_400Regular",
|
|
textAlign: "center",
|
|
lineHeight: 18,
|
|
},
|
|
});
|