feat: UX improvements, security hardening, and code cleanup

- Extract shared extractError utility (lib/extractError.ts), remove 3 duplicate copies
- Export DEFAULT_BASE_URL from PostizContext, remove duplicate in settings
- Add 401 interceptor in axios client: fires UnauthorizedHandler in _layout → alert + redirect to Settings
- Calendar day items now tappable: tap opens context menu (Copy / Edit / Repost)
- Persist sort order (newest/oldest) across sessions via AsyncStorage
- Filter chips show post count per status (Queue 3, Error 1, etc.)
- Copy text action now shows a brief "Copied" toast + haptic feedback
- PostCard: swipe right → Reschedule action (QUEUE posts only, amber color)
- Compose: per-network char limit (Twitter 280, Instagram 2200…) with color warning at 90%
- Compose: local draft save/restore via AsyncStorage with restore banner on open
- Compose: prefillImagePath/prefillImageId params allow Edit/Repost to carry over existing media

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:20:56 +02:00
parent bc0973ccaa
commit 7aacb9a53e
8 changed files with 419 additions and 112 deletions
+170 -10
View File
@@ -1,6 +1,7 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import DateTimePicker from "@react-native-community/datetimepicker"; import DateTimePicker from "@react-native-community/datetimepicker";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { Image } from "expo-image"; import { Image } from "expo-image";
import * as ImagePicker from "expo-image-picker"; import * as ImagePicker from "expo-image-picker";
@@ -24,15 +25,28 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChannelChip } from "@/components/ChannelChip"; import { ChannelChip } from "@/components/ChannelChip";
import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext"; import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
const DRAFT_STORAGE_KEY = "postiz_local_draft";
const NETWORK_CHAR_LIMITS: Record<string, number> = {
twitter: 280, x: 280,
instagram: 2200,
linkedin: 3000,
facebook: 63206,
youtube: 5000,
tiktok: 2200,
};
export default function ComposeScreen() { export default function ComposeScreen() {
const colors = useColors(); const colors = useColors();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { client, isConfigured, apiKey, baseUrl } = usePostiz(); const { client, isConfigured, apiKey, baseUrl } = usePostiz();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds } = useLocalSearchParams<{ const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
useLocalSearchParams<{
prefillContent?: string; prefillContent?: string;
prefillIntegrationIds?: string; prefillIntegrationIds?: string;
prefillImagePath?: string;
prefillImageId?: string;
}>(); }>();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [selectedChannels, setSelectedChannels] = useState<string[]>([]); const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
@@ -43,15 +57,34 @@ export default function ComposeScreen() {
const [showDatePicker, setShowDatePicker] = useState(false); const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false); const [showTimePicker, setShowTimePicker] = useState(false);
const [imageUri, setImageUri] = useState<string | null>(null); const [imageUri, setImageUri] = useState<string | null>(null);
const [existingMedia, setExistingMedia] = useState<Array<{ id: string; path: string }>>([]);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [draftBanner, setDraftBanner] = useState(false);
useEffect(() => { useEffect(() => {
if (prefillContent) setContent(String(prefillContent)); if (prefillContent) {
setContent(String(prefillContent));
}
if (prefillIntegrationIds) { if (prefillIntegrationIds) {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean)); setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
} }
}, [prefillContent, prefillIntegrationIds]); if (prefillImagePath && prefillImageId) {
setExistingMedia([{ id: String(prefillImageId), path: String(prefillImagePath) }]);
setImageUri(String(prefillImagePath));
}
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId]);
useEffect(() => {
if (prefillContent) return;
AsyncStorage.getItem(DRAFT_STORAGE_KEY).then((raw) => {
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (draft?.content) setDraftBanner(true);
} catch {}
});
}, [prefillContent]);
const { data: integrations, isLoading: loadingIntegrations } = const { data: integrations, isLoading: loadingIntegrations } =
useQuery<PostizIntegration[]>({ useQuery<PostizIntegration[]>({
@@ -65,6 +98,39 @@ export default function ComposeScreen() {
staleTime: 60000, staleTime: 60000,
}); });
const effectiveCharLimit = (() => {
if (selectedChannels.length === 0 || !integrations) return 3000;
const selected = integrations.filter((i) => selectedChannels.includes(i.id));
const limits = selected.map((i) => {
const t = (i.type ?? i.internalType ?? "").toLowerCase();
for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) {
if (t.includes(key)) return limit;
}
return 3000;
});
return Math.min(...limits);
})();
const saveDraft = async () => {
const draft = { content, integrationIds: selectedChannels };
await AsyncStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Draft saved", "Your draft has been saved locally.");
};
const restoreDraft = async () => {
const raw = await AsyncStorage.getItem(DRAFT_STORAGE_KEY);
if (!raw) return;
try {
const draft = JSON.parse(raw);
if (draft.content) setContent(draft.content);
if (draft.integrationIds?.length) setSelectedChannels(draft.integrationIds);
setDraftBanner(false);
} catch {}
};
const dismissDraft = () => setDraftBanner(false);
const toggleChannel = (id: string) => { const toggleChannel = (id: string) => {
setSelectedChannels((prev) => setSelectedChannels((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id] prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
@@ -84,10 +150,14 @@ export default function ComposeScreen() {
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri); setImageUri(result.assets[0].uri);
setExistingMedia([]);
} }
}; };
const removeImage = () => setImageUri(null); const removeImage = () => {
setImageUri(null);
setExistingMedia([]);
};
const uploadImage = async (): Promise<PostizUploadResult> => { const uploadImage = async (): Promise<PostizUploadResult> => {
setUploading(true); setUploading(true);
@@ -134,9 +204,12 @@ export default function ComposeScreen() {
setSubmitting(true); setSubmitting(true);
try { try {
let media: Array<{ id: string; path: string }> = []; let media: Array<{ id: string; path: string }> = [];
if (imageUri) { const isLocalFile = imageUri && !imageUri.startsWith("http");
if (imageUri && isLocalFile) {
const uploaded = await uploadImage(); const uploaded = await uploadImage();
media = [{ id: uploaded.id, path: uploaded.path }]; media = [{ id: uploaded.id, path: uploaded.path }];
} else if (existingMedia.length > 0) {
media = existingMedia;
} }
const payload = { const payload = {
type: postNow ? "now" : "schedule", type: postNow ? "now" : "schedule",
@@ -176,6 +249,7 @@ export default function ComposeScreen() {
} }
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
Alert.alert( Alert.alert(
"Posted!", "Posted!",
postNow ? "Your post has been published." : "Post scheduled successfully.", postNow ? "Your post has been published." : "Post scheduled successfully.",
@@ -197,6 +271,8 @@ export default function ComposeScreen() {
setSelectedChannels([]); setSelectedChannels([]);
setPostNow(false); setPostNow(false);
setImageUri(null); setImageUri(null);
setExistingMedia([]);
setDraftBanner(false);
setScheduleDate(new Date(Date.now() + 60 * 60 * 1000)); setScheduleDate(new Date(Date.now() + 60 * 60 * 1000));
}; };
@@ -238,6 +314,21 @@ export default function ComposeScreen() {
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{draftBanner && (
<View style={[styles.draftBanner, { backgroundColor: colors.card, borderColor: colors.border }]}>
<Feather name="file-text" size={14} color={colors.primary} />
<Text style={[styles.draftBannerText, { color: colors.foreground }]}>
You have a saved draft
</Text>
<TouchableOpacity onPress={restoreDraft} activeOpacity={0.7}>
<Text style={[styles.draftBannerAction, { color: colors.primary }]}>Restore</Text>
</TouchableOpacity>
<TouchableOpacity onPress={dismissDraft} activeOpacity={0.7}>
<Feather name="x" size={14} color={colors.mutedForeground} />
</TouchableOpacity>
</View>
)}
<View <View
style={[ style={[
styles.textArea, styles.textArea,
@@ -251,12 +342,31 @@ export default function ComposeScreen() {
multiline multiline
value={content} value={content}
onChangeText={setContent} onChangeText={setContent}
maxLength={3000} maxLength={effectiveCharLimit}
textAlignVertical="top" textAlignVertical="top"
/> />
<Text style={[styles.charCount, { color: colors.mutedForeground }]}> <View style={styles.charCountRow}>
{content.length}/3000 {effectiveCharLimit < 3000 && selectedChannels.length > 0 && (
<Text style={[styles.charCountLabel, { color: colors.mutedForeground }]}>
limit: {effectiveCharLimit}
</Text> </Text>
)}
<Text
style={[
styles.charCount,
{
color:
content.length >= effectiveCharLimit
? colors.error
: content.length > effectiveCharLimit * 0.9
? colors.warning
: colors.mutedForeground,
},
]}
>
{content.length}/{effectiveCharLimit}
</Text>
</View>
</View> </View>
{imageUri && ( {imageUri && (
@@ -410,6 +520,16 @@ export default function ComposeScreen() {
/> />
)} )}
<TouchableOpacity
onPress={saveDraft}
activeOpacity={0.7}
disabled={submitting || uploading || !content.trim()}
style={[styles.draftBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="file-text" size={14} color={colors.mutedForeground} />
<Text style={[styles.draftBtnText, { color: colors.mutedForeground }]}>Save Draft</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={handleSubmit} onPress={handleSubmit}
activeOpacity={0.85} activeOpacity={0.85}
@@ -464,11 +584,51 @@ const styles = StyleSheet.create({
lineHeight: 22, lineHeight: 22,
minHeight: 100, minHeight: 100,
}, },
charCountRow: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
gap: 6,
marginTop: 4,
},
charCountLabel: {
fontSize: 10,
fontFamily: "Inter_400Regular",
},
charCount: { charCount: {
fontSize: 11, fontSize: 11,
fontFamily: "Inter_400Regular", fontFamily: "Inter_400Regular",
alignSelf: "flex-end", },
marginTop: 4, draftBanner: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
draftBannerText: {
flex: 1,
fontSize: 13,
fontFamily: "Inter_400Regular",
},
draftBannerAction: {
fontSize: 13,
fontFamily: "Inter_600SemiBold",
},
draftBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
},
draftBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
}, },
imagePreviewWrap: { imagePreviewWrap: {
position: "relative", position: "relative",
+38 -19
View File
@@ -1,10 +1,12 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import axios from "axios"; import * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics";
import { router } from "expo-router"; import { router } from "expo-router";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
FlatList, FlatList,
Platform, Platform,
StyleSheet, StyleSheet,
@@ -17,24 +19,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostizPost, usePostiz } from "@/context/PostizContext"; import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { extractError } from "@/lib/extractError";
function extractError(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.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}
function formatDate(date: Date): string { function formatDate(date: Date): string {
const y = date.getFullYear(); const y = date.getFullYear();
@@ -65,6 +50,39 @@ export default function CalendarScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { client, isConfigured } = usePostiz(); const { client, isConfigured } = usePostiz();
const showContextMenu = (post: PostizPost) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const preview = post.content.slice(0, 60) + (post.content.length > 60 ? "…" : "");
const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
Alert.alert(
post.state === "PUBLISHED" ? "Published post" :
post.state === "QUEUE" ? "Scheduled post" :
post.state === "ERROR" ? "Failed post" : "Draft",
preview,
[
{
text: "Copy text",
onPress: () => {
Clipboard.setStringAsync(post.content);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
},
},
{
text: post.state === "PUBLISHED" ? "Repost" : "Edit",
onPress: () =>
router.push({
pathname: "/(tabs)/compose",
params: {
prefillContent: post.content,
prefillIntegrationIds: integrations.map((i) => i.id).join(","),
},
}),
},
{ text: "Cancel", style: "cancel" },
]
);
};
const now = new Date(); const now = new Date();
const [currentMonth, setCurrentMonth] = useState({ const [currentMonth, setCurrentMonth] = useState({
year: now.getFullYear(), year: now.getFullYear(),
@@ -255,6 +273,7 @@ export default function CalendarScreen() {
<TouchableOpacity <TouchableOpacity
style={[styles.dayPost, { borderBottomColor: colors.border }]} style={[styles.dayPost, { borderBottomColor: colors.border }]}
activeOpacity={0.7} activeOpacity={0.7}
onPress={() => showContextMenu(item)}
> >
<View style={styles.dayPostLeft}> <View style={styles.dayPostLeft}>
<Text style={[styles.timeText, { color: colors.primary }]}> <Text style={[styles.timeText, { color: colors.primary }]}>
+66 -33
View File
@@ -1,11 +1,11 @@
import { Feather } from "@expo/vector-icons"; import { Feather } from "@expo/vector-icons";
import DateTimePicker from "@react-native-community/datetimepicker"; import DateTimePicker from "@react-native-community/datetimepicker";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios"; import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@@ -21,24 +21,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostCard } from "@/components/PostCard"; import { PostCard } from "@/components/PostCard";
import { PostizPost, usePostiz } from "@/context/PostizContext"; import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { extractError } from "@/lib/extractError";
function extractError(err: unknown): string { const SORT_STORAGE_KEY = "postiz_posts_sort";
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.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}
type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT"; type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT";
@@ -59,6 +44,19 @@ export default function PostsScreen() {
const [filter, setFilter] = useState<FilterType>("all"); const [filter, setFilter] = useState<FilterType>("all");
const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc"); const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc");
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [copyToast, setCopyToast] = useState(false);
useEffect(() => {
AsyncStorage.getItem(SORT_STORAGE_KEY).then((v) => {
if (v === "asc" || v === "desc") setSortOrder(v);
});
}, []);
const toggleSort = () => {
const next = sortOrder === "desc" ? "asc" : "desc";
setSortOrder(next);
AsyncStorage.setItem(SORT_STORAGE_KEY, next);
};
// reschedule state // reschedule state
const [reschedulePost, setReschedulePost] = useState<PostizPost | null>(null); const [reschedulePost, setReschedulePost] = useState<PostizPost | null>(null);
@@ -98,6 +96,17 @@ export default function PostsScreen() {
}); });
}, [posts, filter, sortOrder]); }, [posts, filter, sortOrder]);
const filterCounts = useMemo(() => {
const all = posts ?? [];
return {
all: all.length,
QUEUE: all.filter((p) => p.state === "QUEUE").length,
PUBLISHED: all.filter((p) => p.state === "PUBLISHED").length,
DRAFT: all.filter((p) => p.state === "DRAFT").length,
ERROR: all.filter((p) => p.state === "ERROR").length,
};
}, [posts]);
const handleRefresh = async () => { const handleRefresh = async () => {
setRefreshing(true); setRefreshing(true);
await refetch(); await refetch();
@@ -190,7 +199,12 @@ export default function PostsScreen() {
buttons.push({ buttons.push({
text: "Copy text", text: "Copy text",
onPress: () => Clipboard.setStringAsync(post.content), onPress: async () => {
await Clipboard.setStringAsync(post.content);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setCopyToast(true);
setTimeout(() => setCopyToast(false), 2000);
},
}); });
if (post.state === "ERROR") { if (post.state === "ERROR") {
@@ -258,39 +272,37 @@ export default function PostsScreen() {
keyExtractor={(item) => item.key} keyExtractor={(item) => item.key}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterList} contentContainerStyle={styles.filterList}
renderItem={({ item }) => ( renderItem={({ item }) => {
const count = posts ? filterCounts[item.key] : undefined;
const active = filter === item.key;
return (
<TouchableOpacity <TouchableOpacity
onPress={() => setFilter(item.key)} onPress={() => setFilter(item.key)}
activeOpacity={0.7} activeOpacity={0.7}
style={[ style={[
styles.filterChip, styles.filterChip,
{ {
backgroundColor: backgroundColor: active ? colors.primary : colors.secondary,
filter === item.key ? colors.primary : colors.secondary, borderColor: active ? colors.primary : colors.border,
borderColor:
filter === item.key ? colors.primary : colors.border,
}, },
]} ]}
> >
<Text <Text
style={[ style={[
styles.filterText, styles.filterText,
{ { color: active ? colors.primaryForeground : colors.mutedForeground },
color:
filter === item.key
? colors.primaryForeground
: colors.mutedForeground,
},
]} ]}
> >
{item.label} {item.label}
{count !== undefined && count > 0 ? ` ${count}` : ""}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} );
}}
style={styles.filterBar} style={styles.filterBar}
/> />
<TouchableOpacity <TouchableOpacity
onPress={() => setSortOrder((o) => (o === "desc" ? "asc" : "desc"))} onPress={toggleSort}
activeOpacity={0.7} activeOpacity={0.7}
style={[styles.sortBtn, { borderColor: colors.border, backgroundColor: colors.secondary }]} style={[styles.sortBtn, { borderColor: colors.border, backgroundColor: colors.secondary }]}
> >
@@ -302,6 +314,13 @@ export default function PostsScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{copyToast && (
<View style={[styles.toast, { backgroundColor: colors.success }]}>
<Feather name="check" size={13} color="#fff" />
<Text style={styles.toastText}>Copied</Text>
</View>
)}
{isLoading ? ( {isLoading ? (
<View style={styles.centered}> <View style={styles.centered}>
<ActivityIndicator color={colors.primary} size="large" /> <ActivityIndicator color={colors.primary} size="large" />
@@ -333,6 +352,7 @@ export default function PostsScreen() {
post={item} post={item}
onDelete={handleDelete} onDelete={handleDelete}
onLongPress={showContextMenu} onLongPress={showContextMenu}
onReschedule={startReschedule}
/> />
)} )}
refreshControl={ refreshControl={
@@ -415,4 +435,17 @@ const styles = StyleSheet.create({
emptyText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center" }, emptyText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center" },
retryBtn: { marginTop: 4, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 }, retryBtn: { marginTop: 4, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 },
retryText: { fontSize: 14, fontFamily: "Inter_600SemiBold" }, retryText: { fontSize: 14, fontFamily: "Inter_600SemiBold" },
toast: {
position: "absolute",
bottom: 100,
alignSelf: "center",
flexDirection: "row",
alignItems: "center",
gap: 6,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
zIndex: 999,
},
toastText: { fontSize: 13, fontFamily: "Inter_600SemiBold", color: "#fff" },
}); });
@@ -15,29 +15,9 @@ import {
} from "react-native"; } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"; import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { usePostiz } from "@/context/PostizContext"; import { usePostiz, DEFAULT_BASE_URL } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors"; import { useColors } from "@/hooks/useColors";
import { extractError } from "@/lib/extractError";
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/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() { export default function SettingsScreen() {
const colors = useColors(); const colors = useColors();
@@ -98,7 +78,7 @@ export default function SettingsScreen() {
continue; continue;
} }
} }
lastError = extractAxiosError(err); lastError = extractError(err);
} }
} }
@@ -119,7 +99,7 @@ export default function SettingsScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully."); Alert.alert("Saved", "Settings saved successfully.");
} catch (err: unknown) { } catch (err: unknown) {
Alert.alert("Error", `Failed to save settings.\n${extractAxiosError(err)}`); Alert.alert("Error", `Failed to save settings.\n${extractError(err)}`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
+26 -2
View File
@@ -6,15 +6,16 @@ import {
useFonts, useFonts,
} from "@expo-google-fonts/inter"; } from "@expo-google-fonts/inter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Stack } from "expo-router"; import { router, Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { Alert } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { ErrorBoundary } from "@/components/ErrorBoundary"; import { ErrorBoundary } from "@/components/ErrorBoundary";
import { PostizProvider } from "@/context/PostizContext"; import { PostizProvider, usePostiz } from "@/context/PostizContext";
import { useNotifications } from "@/hooks/useNotifications"; import { useNotifications } from "@/hooks/useNotifications";
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
@@ -33,6 +34,28 @@ function NotificationBootstrap() {
return null; return null;
} }
function UnauthorizedHandler() {
const { unauthorized, clearUnauthorized } = usePostiz();
useEffect(() => {
if (!unauthorized) return;
Alert.alert(
"API key invalid",
"Your API key was rejected (401). Update it in Settings.",
[
{
text: "Open Settings",
onPress: () => {
clearUnauthorized();
router.push("/(tabs)/settings");
},
},
{ text: "Dismiss", style: "cancel", onPress: clearUnauthorized },
]
);
}, [unauthorized, clearUnauthorized]);
return null;
}
function RootLayoutNav() { function RootLayoutNav() {
return ( return (
<Stack screenOptions={{ headerBackTitle: "Back" }}> <Stack screenOptions={{ headerBackTitle: "Back" }}>
@@ -63,6 +86,7 @@ export default function RootLayout() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<PostizProvider> <PostizProvider>
<NotificationBootstrap /> <NotificationBootstrap />
<UnauthorizedHandler />
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider> <KeyboardProvider>
<RootLayoutNav /> <RootLayoutNav />
@@ -18,6 +18,7 @@ interface PostCardProps {
post: PostizPost; post: PostizPost;
onDelete: (id: string) => Promise<void>; onDelete: (id: string) => Promise<void>;
onLongPress: (post: PostizPost) => void; onLongPress: (post: PostizPost) => void;
onReschedule?: (post: PostizPost) => void;
} }
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
@@ -44,7 +45,7 @@ function getNetworkIcon(type?: string): React.ComponentProps<typeof Feather>["na
return "globe"; return "globe";
} }
export function PostCard({ post, onDelete, onLongPress }: PostCardProps) { export function PostCard({ post, onDelete, onLongPress, onReschedule }: PostCardProps) {
const colors = useColors(); const colors = useColors();
const swipeRef = useRef<Swipeable>(null); const swipeRef = useRef<Swipeable>(null);
@@ -88,6 +89,34 @@ export function PostCard({ post, onDelete, onLongPress }: PostCardProps) {
); );
}; };
const renderLeftActions =
post.state === "QUEUE" && onReschedule
? (
_progress: Animated.AnimatedInterpolation<number>,
dragX: Animated.AnimatedInterpolation<number>
) => {
const scale = dragX.interpolate({
inputRange: [0, 80],
outputRange: [0.8, 1],
extrapolate: "clamp",
});
return (
<TouchableOpacity
style={[styles.rescheduleAction, { backgroundColor: colors.warning }]}
onPress={() => {
swipeRef.current?.close();
onReschedule(post);
}}
activeOpacity={0.8}
>
<Animated.View style={{ transform: [{ scale }] }}>
<Feather name="clock" size={20} color="#fff" />
</Animated.View>
</TouchableOpacity>
);
}
: undefined;
const integrations = post.integrations ?? (post.integration ? [post.integration] : []); const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
const truncatedContent = const truncatedContent =
post.content.length > 140 post.content.length > 140
@@ -98,7 +127,9 @@ export function PostCard({ post, onDelete, onLongPress }: PostCardProps) {
<Swipeable <Swipeable
ref={swipeRef} ref={swipeRef}
renderRightActions={renderRightActions} renderRightActions={renderRightActions}
renderLeftActions={renderLeftActions}
rightThreshold={40} rightThreshold={40}
leftThreshold={40}
friction={2} friction={2}
> >
<TouchableOpacity <TouchableOpacity
@@ -218,4 +249,9 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
rescheduleAction: {
width: 72,
alignItems: "center",
justifyContent: "center",
},
}); });
@@ -5,12 +5,13 @@ import React, {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useRef,
useState, useState,
} from "react"; } from "react";
const API_KEY_STORAGE = "postiz_api_key"; const API_KEY_STORAGE = "postiz_api_key";
const BASE_URL_STORAGE = "postiz_base_url"; const BASE_URL_STORAGE = "postiz_base_url";
const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1"; export const DEFAULT_BASE_URL = "https://postiz.gyozamancave.fr/api/public/v1";
export interface PostizIntegration { export interface PostizIntegration {
id: string; id: string;
@@ -48,6 +49,8 @@ interface PostizContextValue {
baseUrl: string; baseUrl: string;
isConfigured: boolean; isConfigured: boolean;
isLoading: boolean; isLoading: boolean;
unauthorized: boolean;
clearUnauthorized: () => void;
client: AxiosInstance | null; client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>; saveSettings: (apiKey: string, baseUrl: string) => Promise<void>;
clearSettings: () => Promise<void>; clearSettings: () => Promise<void>;
@@ -58,12 +61,18 @@ const PostizContext = createContext<PostizContextValue>({
baseUrl: DEFAULT_BASE_URL, baseUrl: DEFAULT_BASE_URL,
isConfigured: false, isConfigured: false,
isLoading: true, isLoading: true,
unauthorized: false,
clearUnauthorized: () => {},
client: null, client: null,
saveSettings: async () => {}, saveSettings: async () => {},
clearSettings: async () => {}, clearSettings: async () => {},
}); });
function createClient(apiKey: string, baseUrl: string): AxiosInstance { function createClient(
apiKey: string,
baseUrl: string,
onUnauthorized?: () => void
): AxiosInstance {
const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"; const normalizedUrl = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
const instance = axios.create({ const instance = axios.create({
baseURL: normalizedUrl, baseURL: normalizedUrl,
@@ -77,6 +86,15 @@ function createClient(apiKey: string, baseUrl: string): AxiosInstance {
console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || "")); console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || ""));
return config; return config;
}); });
instance.interceptors.response.use(
(res) => res,
(err) => {
if (axios.isAxiosError(err) && err.response?.status === 401) {
onUnauthorized?.();
}
return Promise.reject(err);
}
);
return instance; return instance;
} }
@@ -85,6 +103,19 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [client, setClient] = useState<AxiosInstance | null>(null); const [client, setClient] = useState<AxiosInstance | null>(null);
const [unauthorized, setUnauthorized] = useState(false);
const unauthorizedFiredRef = useRef(false);
const handleUnauthorized = useCallback(() => {
if (unauthorizedFiredRef.current) return;
unauthorizedFiredRef.current = true;
setUnauthorized(true);
}, []);
const clearUnauthorized = useCallback(() => {
unauthorizedFiredRef.current = false;
setUnauthorized(false);
}, []);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -95,14 +126,14 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, ""); const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
setApiKey(storedKey); setApiKey(storedKey);
setBaseUrl(url); setBaseUrl(url);
setClient(() => createClient(storedKey, url)); setClient(() => createClient(storedKey, url, handleUnauthorized));
} }
} catch { } catch {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
})(); })();
}, []); }, [handleUnauthorized]);
const saveSettings = useCallback( const saveSettings = useCallback(
async (newApiKey: string, newBaseUrl: string) => { async (newApiKey: string, newBaseUrl: string) => {
@@ -110,9 +141,10 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl); await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl);
setApiKey(newApiKey); setApiKey(newApiKey);
setBaseUrl(newBaseUrl); setBaseUrl(newBaseUrl);
setClient(() => createClient(newApiKey, newBaseUrl)); clearUnauthorized();
setClient(() => createClient(newApiKey, newBaseUrl, handleUnauthorized));
}, },
[] [handleUnauthorized, clearUnauthorized]
); );
const clearSettings = useCallback(async () => { const clearSettings = useCallback(async () => {
@@ -121,7 +153,8 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
setApiKey(""); setApiKey("");
setBaseUrl(DEFAULT_BASE_URL); setBaseUrl(DEFAULT_BASE_URL);
setClient(null); setClient(null);
}, []); clearUnauthorized();
}, [clearUnauthorized]);
return ( return (
<PostizContext.Provider <PostizContext.Provider
@@ -130,6 +163,8 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
baseUrl, baseUrl,
isConfigured: !!apiKey, isConfigured: !!apiKey,
isLoading, isLoading,
unauthorized,
clearUnauthorized,
client, client,
saveSettings, saveSettings,
clearSettings, clearSettings,
@@ -0,0 +1,20 @@
import axios from "axios";
export function extractError(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. Check that the URL is reachable.";
if (err.message) return err.message;
}
if (err instanceof Error) return err.message;
return "Unknown error";
}