0cf5800463
Release APK / build (push) Has been cancelled
- Pass post.image[] as JSON prefillImages param when prefilling compose from an existing post (repost/edit/retry), replacing the broken single-image prefillImagePath/prefillImageId approach - MediaLibraryModal now shows the attempted URL and a clear explanation on 404, making it easier to diagnose if the Postiz version does not expose GET /media - stripHtml accepts null/undefined input and returns "" instead of throwing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
455 lines
16 KiB
TypeScript
455 lines
16 KiB
TypeScript
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<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);
|
|
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<PostizPost[]>({
|
|
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] : []);
|
|
const params: Record<string, string> = {
|
|
prefillContent: stripHtml(post.content),
|
|
prefillIntegrationIds: integrations.map((i) => i.id).join(","),
|
|
};
|
|
if (post.image?.length) {
|
|
params.prefillImages = JSON.stringify(post.image);
|
|
}
|
|
router.push({ pathname: "/(tabs)/compose", params });
|
|
};
|
|
|
|
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 (
|
|
<View
|
|
style={[
|
|
styles.centered,
|
|
{ backgroundColor: colors.background, paddingTop: Platform.OS === "web" ? 67 : 0 },
|
|
]}
|
|
>
|
|
<Feather name="lock" size={32} color={colors.mutedForeground} />
|
|
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
|
Not Configured
|
|
</Text>
|
|
<Text style={[styles.emptyText, { color: colors.mutedForeground }]}>
|
|
Add your API key in Settings
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.container,
|
|
{
|
|
backgroundColor: colors.background,
|
|
paddingTop: Platform.OS === "web" ? 67 : 0,
|
|
},
|
|
]}
|
|
>
|
|
<View style={[styles.filterRow, { borderBottomColor: colors.border }]}>
|
|
<FlatList
|
|
horizontal
|
|
data={FILTERS}
|
|
keyExtractor={(item) => item.key}
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.filterList}
|
|
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: active ? colors.primary : colors.secondary,
|
|
borderColor: active ? colors.primary : colors.border,
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.filterText,
|
|
{ color: active ? colors.primaryForeground : colors.mutedForeground },
|
|
]}
|
|
>
|
|
{item.label}
|
|
{count !== undefined && count > 0 ? ` ${count}` : ""}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
}}
|
|
style={styles.filterBar}
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={toggleSort}
|
|
activeOpacity={0.7}
|
|
style={[styles.sortBtn, { borderColor: colors.border, backgroundColor: colors.secondary }]}
|
|
>
|
|
<Feather
|
|
name={sortOrder === "desc" ? "arrow-down" : "arrow-up"}
|
|
size={14}
|
|
color={colors.mutedForeground}
|
|
/>
|
|
</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" />
|
|
</View>
|
|
) : error ? (
|
|
<View style={styles.centered}>
|
|
<Feather name="alert-circle" size={28} color={colors.error} />
|
|
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
|
Failed to load
|
|
</Text>
|
|
<Text style={[styles.emptyText, { color: colors.mutedForeground }]} selectable>
|
|
{extractError(error)}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={() => refetch()}
|
|
style={[styles.retryBtn, { backgroundColor: colors.primary }]}
|
|
>
|
|
<Text style={[styles.retryText, { color: colors.primaryForeground }]}>
|
|
Try Again
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={filteredPosts}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={({ item }) => (
|
|
<PostCard
|
|
post={item}
|
|
onDelete={handleDelete}
|
|
onLongPress={showContextMenu}
|
|
onReschedule={startReschedule}
|
|
/>
|
|
)}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={handleRefresh}
|
|
tintColor={colors.primary}
|
|
/>
|
|
}
|
|
contentInsetAdjustmentBehavior="automatic"
|
|
showsVerticalScrollIndicator={false}
|
|
ListEmptyComponent={
|
|
<View style={styles.emptyState}>
|
|
<Feather name="inbox" size={36} color={colors.mutedForeground} />
|
|
<Text style={[styles.emptyTitle, { color: colors.foreground }]}>
|
|
No posts
|
|
</Text>
|
|
<Text style={[styles.emptyText, { color: colors.mutedForeground }]}>
|
|
{filter === "all"
|
|
? "No posts found in the last 3 months"
|
|
: `No ${filter.toLowerCase()} posts`}
|
|
</Text>
|
|
</View>
|
|
}
|
|
scrollEnabled={filteredPosts.length > 0}
|
|
/>
|
|
)}
|
|
|
|
{rescheduleStep !== null && reschedulePost !== null && (
|
|
<DateTimePicker
|
|
value={rescheduleDate}
|
|
mode={rescheduleStep}
|
|
display="default"
|
|
minimumDate={rescheduleStep === "date" ? new Date() : undefined}
|
|
textColor={colors.foreground}
|
|
accentColor={colors.primary}
|
|
onChange={(_: unknown, date?: Date) => {
|
|
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);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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" },
|
|
});
|