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:
@@ -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,16 +25,29 @@ 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 } =
|
||||||
prefillContent?: string;
|
useLocalSearchParams<{
|
||||||
prefillIntegrationIds?: string;
|
prefillContent?: 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[]>([]);
|
||||||
const [postNow, setPostNow] = useState(false);
|
const [postNow, setPostNow] = useState(false);
|
||||||
@@ -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>
|
<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>
|
</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",
|
||||||
|
|||||||
@@ -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 }]}>
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
<TouchableOpacity
|
const count = posts ? filterCounts[item.key] : undefined;
|
||||||
onPress={() => setFilter(item.key)}
|
const active = filter === item.key;
|
||||||
activeOpacity={0.7}
|
return (
|
||||||
style={[
|
<TouchableOpacity
|
||||||
styles.filterChip,
|
onPress={() => setFilter(item.key)}
|
||||||
{
|
activeOpacity={0.7}
|
||||||
backgroundColor:
|
|
||||||
filter === item.key ? colors.primary : colors.secondary,
|
|
||||||
borderColor:
|
|
||||||
filter === item.key ? colors.primary : colors.border,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
style={[
|
||||||
styles.filterText,
|
styles.filterChip,
|
||||||
{
|
{
|
||||||
color:
|
backgroundColor: active ? colors.primary : colors.secondary,
|
||||||
filter === item.key
|
borderColor: active ? colors.primary : colors.border,
|
||||||
? colors.primaryForeground
|
|
||||||
: colors.mutedForeground,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{item.label}
|
<Text
|
||||||
</Text>
|
style={[
|
||||||
</TouchableOpacity>
|
styles.filterText,
|
||||||
)}
|
{ color: active ? colors.primaryForeground : colors.mutedForeground },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
{count !== undefined && count > 0 ? ` ${count}` : ""}
|
||||||
|
</Text>
|
||||||
|
</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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user