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
+174 -14
View File
@@ -1,6 +1,7 @@
import { Feather } from "@expo/vector-icons";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import DateTimePicker from "@react-native-community/datetimepicker";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Haptics from "expo-haptics";
import { Image } from "expo-image";
import * as ImagePicker from "expo-image-picker";
@@ -24,16 +25,29 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ChannelChip } from "@/components/ChannelChip";
import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext";
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() {
const colors = useColors();
const insets = useSafeAreaInsets();
const { client, isConfigured, apiKey, baseUrl } = usePostiz();
const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds } = useLocalSearchParams<{
prefillContent?: string;
prefillIntegrationIds?: string;
}>();
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
useLocalSearchParams<{
prefillContent?: string;
prefillIntegrationIds?: string;
prefillImagePath?: string;
prefillImageId?: string;
}>();
const [content, setContent] = useState("");
const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
const [postNow, setPostNow] = useState(false);
@@ -43,15 +57,34 @@ export default function ComposeScreen() {
const [showDatePicker, setShowDatePicker] = useState(false);
const [showTimePicker, setShowTimePicker] = useState(false);
const [imageUri, setImageUri] = useState<string | null>(null);
const [existingMedia, setExistingMedia] = useState<Array<{ id: string; path: string }>>([]);
const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [draftBanner, setDraftBanner] = useState(false);
useEffect(() => {
if (prefillContent) setContent(String(prefillContent));
if (prefillContent) {
setContent(String(prefillContent));
}
if (prefillIntegrationIds) {
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 } =
useQuery<PostizIntegration[]>({
@@ -65,6 +98,39 @@ export default function ComposeScreen() {
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) => {
setSelectedChannels((prev) =>
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
@@ -84,10 +150,14 @@ export default function ComposeScreen() {
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setExistingMedia([]);
}
};
const removeImage = () => setImageUri(null);
const removeImage = () => {
setImageUri(null);
setExistingMedia([]);
};
const uploadImage = async (): Promise<PostizUploadResult> => {
setUploading(true);
@@ -134,9 +204,12 @@ export default function ComposeScreen() {
setSubmitting(true);
try {
let media: Array<{ id: string; path: string }> = [];
if (imageUri) {
const isLocalFile = imageUri && !imageUri.startsWith("http");
if (imageUri && isLocalFile) {
const uploaded = await uploadImage();
media = [{ id: uploaded.id, path: uploaded.path }];
} else if (existingMedia.length > 0) {
media = existingMedia;
}
const payload = {
type: postNow ? "now" : "schedule",
@@ -176,6 +249,7 @@ export default function ComposeScreen() {
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
Alert.alert(
"Posted!",
postNow ? "Your post has been published." : "Post scheduled successfully.",
@@ -197,6 +271,8 @@ export default function ComposeScreen() {
setSelectedChannels([]);
setPostNow(false);
setImageUri(null);
setExistingMedia([]);
setDraftBanner(false);
setScheduleDate(new Date(Date.now() + 60 * 60 * 1000));
};
@@ -238,6 +314,21 @@ export default function ComposeScreen() {
keyboardShouldPersistTaps="handled"
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
style={[
styles.textArea,
@@ -251,12 +342,31 @@ export default function ComposeScreen() {
multiline
value={content}
onChangeText={setContent}
maxLength={3000}
maxLength={effectiveCharLimit}
textAlignVertical="top"
/>
<Text style={[styles.charCount, { color: colors.mutedForeground }]}>
{content.length}/3000
</Text>
<View style={styles.charCountRow}>
{effectiveCharLimit < 3000 && selectedChannels.length > 0 && (
<Text style={[styles.charCountLabel, { color: colors.mutedForeground }]}>
limit: {effectiveCharLimit}
</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>
{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
onPress={handleSubmit}
activeOpacity={0.85}
@@ -464,11 +584,51 @@ const styles = StyleSheet.create({
lineHeight: 22,
minHeight: 100,
},
charCountRow: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
gap: 6,
marginTop: 4,
},
charCountLabel: {
fontSize: 10,
fontFamily: "Inter_400Regular",
},
charCount: {
fontSize: 11,
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: {
position: "relative",
+38 -19
View File
@@ -1,10 +1,12 @@
import { Feather } from "@expo/vector-icons";
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 React, { useMemo, useState } from "react";
import {
ActivityIndicator,
Alert,
FlatList,
Platform,
StyleSheet,
@@ -17,24 +19,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
import { StatusBadge } from "@/components/StatusBadge";
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";
}
import { extractError } from "@/lib/extractError";
function formatDate(date: Date): string {
const y = date.getFullYear();
@@ -65,6 +50,39 @@ export default function CalendarScreen() {
const insets = useSafeAreaInsets();
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 [currentMonth, setCurrentMonth] = useState({
year: now.getFullYear(),
@@ -255,6 +273,7 @@ export default function CalendarScreen() {
<TouchableOpacity
style={[styles.dayPost, { borderBottomColor: colors.border }]}
activeOpacity={0.7}
onPress={() => showContextMenu(item)}
>
<View style={styles.dayPostLeft}>
<Text style={[styles.timeText, { color: colors.primary }]}>
+78 -45
View File
@@ -1,11 +1,11 @@
import { Feather } from "@expo/vector-icons";
import DateTimePicker from "@react-native-community/datetimepicker";
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 Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Alert,
@@ -21,24 +21,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { PostCard } from "@/components/PostCard";
import { PostizPost, usePostiz } from "@/context/PostizContext";
import { useColors } from "@/hooks/useColors";
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";
}
const SORT_STORAGE_KEY = "postiz_posts_sort";
type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT";
@@ -59,6 +44,19 @@ export default function PostsScreen() {
const [filter, setFilter] = useState<FilterType>("all");
const [sortOrder, setSortOrder] = useState<"desc" | "asc">("desc");
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
const [reschedulePost, setReschedulePost] = useState<PostizPost | null>(null);
@@ -98,6 +96,17 @@ export default function PostsScreen() {
});
}, [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 () => {
setRefreshing(true);
await refetch();
@@ -190,7 +199,12 @@ export default function PostsScreen() {
buttons.push({
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") {
@@ -258,39 +272,37 @@ export default function PostsScreen() {
keyExtractor={(item) => item.key}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterList}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => setFilter(item.key)}
activeOpacity={0.7}
style={[
styles.filterChip,
{
backgroundColor:
filter === item.key ? colors.primary : colors.secondary,
borderColor:
filter === item.key ? colors.primary : colors.border,
},
]}
>
<Text
renderItem={({ item }) => {
const count = posts ? filterCounts[item.key] : undefined;
const active = filter === item.key;
return (
<TouchableOpacity
onPress={() => setFilter(item.key)}
activeOpacity={0.7}
style={[
styles.filterText,
styles.filterChip,
{
color:
filter === item.key
? colors.primaryForeground
: colors.mutedForeground,
backgroundColor: active ? colors.primary : colors.secondary,
borderColor: active ? colors.primary : colors.border,
},
]}
>
{item.label}
</Text>
</TouchableOpacity>
)}
<Text
style={[
styles.filterText,
{ color: active ? colors.primaryForeground : colors.mutedForeground },
]}
>
{item.label}
{count !== undefined && count > 0 ? ` ${count}` : ""}
</Text>
</TouchableOpacity>
);
}}
style={styles.filterBar}
/>
<TouchableOpacity
onPress={() => setSortOrder((o) => (o === "desc" ? "asc" : "desc"))}
onPress={toggleSort}
activeOpacity={0.7}
style={[styles.sortBtn, { borderColor: colors.border, backgroundColor: colors.secondary }]}
>
@@ -302,6 +314,13 @@ export default function PostsScreen() {
</TouchableOpacity>
</View>
{copyToast && (
<View style={[styles.toast, { backgroundColor: colors.success }]}>
<Feather name="check" size={13} color="#fff" />
<Text style={styles.toastText}>Copied</Text>
</View>
)}
{isLoading ? (
<View style={styles.centered}>
<ActivityIndicator color={colors.primary} size="large" />
@@ -333,6 +352,7 @@ export default function PostsScreen() {
post={item}
onDelete={handleDelete}
onLongPress={showContextMenu}
onReschedule={startReschedule}
/>
)}
refreshControl={
@@ -415,4 +435,17 @@ const styles = StyleSheet.create({
emptyText: { fontSize: 14, fontFamily: "Inter_400Regular", textAlign: "center" },
retryBtn: { marginTop: 4, paddingHorizontal: 20, paddingVertical: 10, borderRadius: 10 },
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";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
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";
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";
}
import { extractError } from "@/lib/extractError";
export default function SettingsScreen() {
const colors = useColors();
@@ -98,7 +78,7 @@ export default function SettingsScreen() {
continue;
}
}
lastError = extractAxiosError(err);
lastError = extractError(err);
}
}
@@ -119,7 +99,7 @@ export default function SettingsScreen() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
Alert.alert("Saved", "Settings saved successfully.");
} catch (err: unknown) {
Alert.alert("Error", `Failed to save settings.\n${extractAxiosError(err)}`);
Alert.alert("Error", `Failed to save settings.\n${extractError(err)}`);
} finally {
setSaving(false);
}
+26 -2
View File
@@ -6,15 +6,16 @@ import {
useFonts,
} from "@expo-google-fonts/inter";
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 React, { useEffect } from "react";
import { Alert } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { PostizProvider } from "@/context/PostizContext";
import { PostizProvider, usePostiz } from "@/context/PostizContext";
import { useNotifications } from "@/hooks/useNotifications";
SplashScreen.preventAutoHideAsync();
@@ -33,6 +34,28 @@ function NotificationBootstrap() {
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() {
return (
<Stack screenOptions={{ headerBackTitle: "Back" }}>
@@ -63,6 +86,7 @@ export default function RootLayout() {
<QueryClientProvider client={queryClient}>
<PostizProvider>
<NotificationBootstrap />
<UnauthorizedHandler />
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<RootLayoutNav />
@@ -18,6 +18,7 @@ interface PostCardProps {
post: PostizPost;
onDelete: (id: string) => Promise<void>;
onLongPress: (post: PostizPost) => void;
onReschedule?: (post: PostizPost) => void;
}
function formatDate(dateStr: string): string {
@@ -44,7 +45,7 @@ function getNetworkIcon(type?: string): React.ComponentProps<typeof Feather>["na
return "globe";
}
export function PostCard({ post, onDelete, onLongPress }: PostCardProps) {
export function PostCard({ post, onDelete, onLongPress, onReschedule }: PostCardProps) {
const colors = useColors();
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 truncatedContent =
post.content.length > 140
@@ -98,7 +127,9 @@ export function PostCard({ post, onDelete, onLongPress }: PostCardProps) {
<Swipeable
ref={swipeRef}
renderRightActions={renderRightActions}
renderLeftActions={renderLeftActions}
rightThreshold={40}
leftThreshold={40}
friction={2}
>
<TouchableOpacity
@@ -218,4 +249,9 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "center",
},
rescheduleAction: {
width: 72,
alignItems: "center",
justifyContent: "center",
},
});
@@ -5,12 +5,13 @@ import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
const API_KEY_STORAGE = "postiz_api_key";
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 {
id: string;
@@ -48,6 +49,8 @@ interface PostizContextValue {
baseUrl: string;
isConfigured: boolean;
isLoading: boolean;
unauthorized: boolean;
clearUnauthorized: () => void;
client: AxiosInstance | null;
saveSettings: (apiKey: string, baseUrl: string) => Promise<void>;
clearSettings: () => Promise<void>;
@@ -58,12 +61,18 @@ const PostizContext = createContext<PostizContextValue>({
baseUrl: DEFAULT_BASE_URL,
isConfigured: false,
isLoading: true,
unauthorized: false,
clearUnauthorized: () => {},
client: null,
saveSettings: 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 instance = axios.create({
baseURL: normalizedUrl,
@@ -77,6 +86,15 @@ function createClient(apiKey: string, baseUrl: string): AxiosInstance {
console.log(">>> REQUEST:", config.method?.toUpperCase(), (config.baseURL || "") + (config.url || ""));
return config;
});
instance.interceptors.response.use(
(res) => res,
(err) => {
if (axios.isAxiosError(err) && err.response?.status === 401) {
onUnauthorized?.();
}
return Promise.reject(err);
}
);
return instance;
}
@@ -85,6 +103,19 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
const [isLoading, setIsLoading] = useState(true);
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(() => {
(async () => {
@@ -95,14 +126,14 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
const url = (storedUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
setApiKey(storedKey);
setBaseUrl(url);
setClient(() => createClient(storedKey, url));
setClient(() => createClient(storedKey, url, handleUnauthorized));
}
} catch {
} finally {
setIsLoading(false);
}
})();
}, []);
}, [handleUnauthorized]);
const saveSettings = useCallback(
async (newApiKey: string, newBaseUrl: string) => {
@@ -110,9 +141,10 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
await SecureStore.setItemAsync(BASE_URL_STORAGE, newBaseUrl);
setApiKey(newApiKey);
setBaseUrl(newBaseUrl);
setClient(() => createClient(newApiKey, newBaseUrl));
clearUnauthorized();
setClient(() => createClient(newApiKey, newBaseUrl, handleUnauthorized));
},
[]
[handleUnauthorized, clearUnauthorized]
);
const clearSettings = useCallback(async () => {
@@ -121,7 +153,8 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
setApiKey("");
setBaseUrl(DEFAULT_BASE_URL);
setClient(null);
}, []);
clearUnauthorized();
}, [clearUnauthorized]);
return (
<PostizContext.Provider
@@ -130,6 +163,8 @@ export function PostizProvider({ children }: { children: React.ReactNode }) {
baseUrl,
isConfigured: !!apiKey,
isLoading,
unauthorized,
clearUnauthorized,
client,
saveSettings,
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";
}