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 { 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,15 +25,28 @@ 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<{
|
||||
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
|
||||
useLocalSearchParams<{
|
||||
prefillContent?: string;
|
||||
prefillIntegrationIds?: string;
|
||||
prefillImagePath?: string;
|
||||
prefillImageId?: string;
|
||||
}>();
|
||||
const [content, setContent] = useState("");
|
||||
const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
|
||||
@@ -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
|
||||
<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",
|
||||
|
||||
@@ -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 }]}>
|
||||
|
||||
@@ -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 }) => (
|
||||
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.filterChip,
|
||||
{
|
||||
backgroundColor:
|
||||
filter === item.key ? colors.primary : colors.secondary,
|
||||
borderColor:
|
||||
filter === item.key ? colors.primary : colors.border,
|
||||
backgroundColor: active ? colors.primary : colors.secondary,
|
||||
borderColor: active ? colors.primary : colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterText,
|
||||
{
|
||||
color:
|
||||
filter === item.key
|
||||
? colors.primaryForeground
|
||||
: colors.mutedForeground,
|
||||
},
|
||||
{ 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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user