import { Feather } from "@expo/vector-icons"; import DateTimePicker from "@react-native-community/datetimepicker"; import { useQuery, useQueryClient } from "@tanstack/react-query"; 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, { useEffect, useMemo, useState } from "react"; import { ActivityIndicator, Alert, FlatList, Platform, RefreshControl, StyleSheet, Text, TouchableOpacity, View, } from "react-native"; 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"; import { stripHtml } from "@/lib/stripHtml"; const SORT_STORAGE_KEY = "postiz_posts_sort"; type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT"; const FILTERS: { key: FilterType; label: string }[] = [ { key: "all", label: "All" }, { key: "QUEUE", label: "Queue" }, { key: "PUBLISHED", label: "Published" }, { key: "DRAFT", label: "Draft" }, { key: "ERROR", label: "Error" }, ]; export default function PostsScreen() { const colors = useColors(); const insets = useSafeAreaInsets(); const { client, isConfigured } = usePostiz(); const queryClient = useQueryClient(); const router = useRouter(); const [filter, setFilter] = useState("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(null); const [rescheduleDate, setRescheduleDate] = useState(new Date()); const [rescheduleStep, setRescheduleStep] = useState<"date" | "time" | null>(null); const { startDate, endDate } = useMemo(() => { const s = new Date(); s.setMonth(s.getMonth() - 3); const e = new Date(); e.setMonth(e.getMonth() + 6); return { startDate: s.toISOString(), endDate: e.toISOString() }; }, []); const { data: posts, isLoading, error, refetch } = useQuery({ queryKey: ["posts-list", !!client, startDate, endDate], queryFn: async () => { if (!client) return []; const res = await client.get("posts", { params: { startDate, endDate }, }); return Array.isArray(res.data) ? res.data : res.data?.posts ?? []; }, enabled: !!client, retry: 1, staleTime: 0, }); const filteredPosts = useMemo(() => { const list = filter === "all" ? posts ?? [] : (posts ?? []).filter((p) => p.state === filter); return [...list].sort((a, b) => { const diff = new Date(a.publishDate).getTime() - new Date(b.publishDate).getTime(); return sortOrder === "desc" ? -diff : diff; }); }, [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(); setRefreshing(false); }; const handleDelete = async (id: string) => { if (!client) return; try { await client.delete(`posts/${id}`); queryClient.invalidateQueries({ queryKey: ["posts-list"] }); queryClient.invalidateQueries({ queryKey: ["posts"] }); } catch (e: unknown) { const msg = extractError(e); Alert.alert("Delete failed", msg); } }; const handleRetry = async (post: PostizPost) => { if (!client) return; const integrations = post.integrations ?? (post.integration ? [post.integration] : []); try { const payload = { type: "now", date: new Date().toISOString(), shortLink: false, tags: [] as string[], posts: integrations.map((intg) => ({ integration: { id: intg.id }, value: [{ content: post.content, id: "", image: post.image ?? [] }], })), }; await client.post("posts", payload); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); queryClient.invalidateQueries({ queryKey: ["posts-list"] }); Alert.alert("Retried", "Post submitted again."); } catch (e: unknown) { const msg = extractError(e); Alert.alert("Retry failed", msg); } }; const handlePrefillCompose = (post: PostizPost) => { const integrations = post.integrations ?? (post.integration ? [post.integration] : []); router.push({ pathname: "/(tabs)/compose", params: { prefillContent: post.content, prefillIntegrationIds: integrations.map((i) => i.id).join(","), }, }); }; const startReschedule = (post: PostizPost) => { setReschedulePost(post); setRescheduleDate(new Date(post.publishDate)); setRescheduleStep("date"); }; const submitReschedule = async (post: PostizPost, date: Date) => { if (!client) return; const integrations = post.integrations ?? (post.integration ? [post.integration] : []); try { await client.delete(`posts/${post.id}`); await client.post("posts", { type: "schedule", date: date.toISOString(), shortLink: false, tags: [] as string[], posts: integrations.map((intg) => ({ integration: { id: intg.id }, value: [{ content: post.content, image: post.image ?? [] }], })), }); queryClient.invalidateQueries({ queryKey: ["posts-list"] }); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Alert.alert("Rescheduled", `Post moved to ${date.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`); } catch (e: unknown) { const msg = extractError(e); Alert.alert("Reschedule failed", msg); } }; const showContextMenu = (post: PostizPost) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); const plain = stripHtml(post.content); const preview = plain.slice(0, 60) + (plain.length > 60 ? "…" : ""); const buttons: Array<{ text: string; style?: "cancel" | "destructive" | "default"; onPress?: () => void }> = []; buttons.push({ text: "Copy text", onPress: async () => { await Clipboard.setStringAsync(stripHtml(post.content)); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); setCopyToast(true); setTimeout(() => setCopyToast(false), 2000); }, }); if (post.state === "ERROR") { if (post.errorMessage) { buttons.push({ text: "View error", onPress: () => Alert.alert("Error details", post.errorMessage), }); } buttons.push({ text: "Retry now", onPress: () => handleRetry(post) }); buttons.push({ text: "Edit & retry", onPress: () => handlePrefillCompose(post) }); } else if (post.state === "QUEUE") { buttons.push({ text: "Edit", onPress: () => handlePrefillCompose(post) }); buttons.push({ text: "Reschedule", onPress: () => startReschedule(post) }); } else if (post.state === "PUBLISHED") { buttons.push({ text: "Repost", onPress: () => handlePrefillCompose(post) }); } else if (post.state === "DRAFT") { buttons.push({ text: "Edit & schedule", onPress: () => handlePrefillCompose(post) }); } buttons.push({ text: "Cancel", style: "cancel" }); Alert.alert( post.state === "ERROR" ? "Failed post" : post.state === "QUEUE" ? "Scheduled post" : post.state === "PUBLISHED" ? "Published post" : "Draft", preview, buttons ); }; if (!isConfigured) { return ( Not Configured Add your API key in Settings ); } return ( item.key} showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterList} renderItem={({ item }) => { const count = posts ? filterCounts[item.key] : undefined; const active = filter === item.key; return ( setFilter(item.key)} activeOpacity={0.7} style={[ styles.filterChip, { backgroundColor: active ? colors.primary : colors.secondary, borderColor: active ? colors.primary : colors.border, }, ]} > {item.label} {count !== undefined && count > 0 ? ` ${count}` : ""} ); }} style={styles.filterBar} /> {copyToast && ( Copied )} {isLoading ? ( ) : error ? ( Failed to load {extractError(error)} refetch()} style={[styles.retryBtn, { backgroundColor: colors.primary }]} > Try Again ) : ( item.id} renderItem={({ item }) => ( )} refreshControl={ } contentInsetAdjustmentBehavior="automatic" showsVerticalScrollIndicator={false} ListEmptyComponent={ No posts {filter === "all" ? "No posts found in the last 3 months" : `No ${filter.toLowerCase()} posts`} } scrollEnabled={filteredPosts.length > 0} /> )} {rescheduleStep !== null && reschedulePost !== null && ( { if (!date) { setRescheduleStep(null); setReschedulePost(null); return; } if (rescheduleStep === "date") { const merged = new Date(rescheduleDate); merged.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); setRescheduleDate(merged); setRescheduleStep("time"); } else { const merged = new Date(rescheduleDate); merged.setHours(date.getHours(), date.getMinutes()); const post = reschedulePost; setRescheduleStep(null); setReschedulePost(null); submitReschedule(post, merged); } }} /> )} ); } const styles = StyleSheet.create({ container: { flex: 1 }, centered: { flex: 1, alignItems: "center", justifyContent: "center", gap: 10, paddingHorizontal: 32, }, filterRow: { flexDirection: "row", alignItems: "center", borderBottomWidth: StyleSheet.hairlineWidth }, filterBar: { flex: 1 }, filterList: { paddingHorizontal: 16, paddingVertical: 10, gap: 8 }, filterChip: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 20, borderWidth: 1 }, sortBtn: { marginRight: 12, padding: 7, borderRadius: 8, borderWidth: 1 }, filterText: { fontSize: 13, fontFamily: "Inter_500Medium" }, emptyState: { alignItems: "center", paddingTop: 64, gap: 10 }, emptyTitle: { fontSize: 18, fontFamily: "Inter_600SemiBold" }, 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" }, });