Files
Postiz-android/artifacts/postiz-mobile/app/(tabs)/posts.tsx
T
billisdead 55d283c264 fix: reschedule via delete+recreate, sort posts chrono, show account name
- Reschedule: Postiz public API v1 has no PUT/PATCH on posts; implement
  as delete + recreate with updated date and same content/integrations
- Posts list: sort ascending by publishDate so nearest post appears first
- PostCard footer: show integration name (or identifier) before the
  timestamp, truncated to 2 accounts with +N overflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 13:19:41 +02:00

402 lines
13 KiB
TypeScript

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 * as Clipboard from "expo-clipboard";
import * as Haptics from "expo-haptics";
import { useRouter } from "expo-router";
import React, { 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";
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";
}
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 [refreshing, setRefreshing] = useState(false);
// 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) => new Date(a.publishDate).getTime() - new Date(b.publishDate).getTime()
);
}, [posts, filter]);
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 preview = post.content.slice(0, 60) + (post.content.length > 60 ? "…" : "");
const buttons: Array<{ text: string; style?: "cancel" | "destructive" | "default"; onPress?: () => void }> = [];
buttons.push({
text: "Copy text",
onPress: () => Clipboard.setStringAsync(post.content),
});
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,
},
]}
>
<FlatList
horizontal
data={FILTERS}
keyExtractor={(item) => item.key}
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filterList}
renderItem={({ item }) => (
<TouchableOpacity
onPress={() => setFilter(item.key)}
activeOpacity={0.7}
style={[
styles.filterChip,
{
backgroundColor:
filter === item.key ? colors.primary : colors.secondary,
borderColor:
filter === item.key ? colors.primary : colors.border,
},
]}
>
<Text
style={[
styles.filterText,
{
color:
filter === item.key
? colors.primaryForeground
: colors.mutedForeground,
},
]}
>
{item.label}
</Text>
</TouchableOpacity>
)}
style={[styles.filterBar, { borderBottomColor: colors.border }]}
/>
{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}
/>
)}
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,
},
filterBar: { borderBottomWidth: StyleSheet.hairlineWidth, flexGrow: 0 },
filterList: { paddingHorizontal: 16, paddingVertical: 10, gap: 8 },
filterChip: { paddingHorizontal: 14, paddingVertical: 6, borderRadius: 20, 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" },
});