Files
Postiz-android/artifacts/postiz-mobile/components/PostCard.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

222 lines
5.9 KiB
TypeScript

import { Feather } from "@expo/vector-icons";
import * as Haptics from "expo-haptics";
import React, { useRef } from "react";
import {
Alert,
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { Swipeable } from "react-native-gesture-handler";
import { useColors } from "@/hooks/useColors";
import { PostizPost } from "@/context/PostizContext";
import { StatusBadge } from "./StatusBadge";
interface PostCardProps {
post: PostizPost;
onDelete: (id: string) => Promise<void>;
onLongPress: (post: PostizPost) => void;
}
function formatDate(dateStr: string): string {
try {
const d = new Date(dateStr);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
}
function getNetworkIcon(type?: string): React.ComponentProps<typeof Feather>["name"] {
const t = (type ?? "").toLowerCase();
if (t.includes("twitter") || t.includes("x")) return "twitter";
if (t.includes("linkedin")) return "linkedin";
if (t.includes("instagram")) return "instagram";
if (t.includes("facebook")) return "facebook";
if (t.includes("youtube")) return "youtube";
return "globe";
}
export function PostCard({ post, onDelete, onLongPress }: PostCardProps) {
const colors = useColors();
const swipeRef = useRef<Swipeable>(null);
const handleDelete = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
Alert.alert("Delete Post", "Are you sure you want to delete this post?", [
{
text: "Cancel",
style: "cancel",
onPress: () => swipeRef.current?.close(),
},
{
text: "Delete",
style: "destructive",
onPress: async () => {
await onDelete(post.id);
},
},
]);
};
const renderRightActions = (
_progress: Animated.AnimatedInterpolation<number>,
dragX: Animated.AnimatedInterpolation<number>
) => {
const scale = dragX.interpolate({
inputRange: [-80, 0],
outputRange: [1, 0.8],
extrapolate: "clamp",
});
return (
<TouchableOpacity
style={[styles.deleteAction, { backgroundColor: colors.destructive }]}
onPress={handleDelete}
activeOpacity={0.8}
>
<Animated.View style={{ transform: [{ scale }] }}>
<Feather name="trash-2" size={20} color="#fff" />
</Animated.View>
</TouchableOpacity>
);
};
const integrations = post.integrations ?? (post.integration ? [post.integration] : []);
const truncatedContent =
post.content.length > 140
? post.content.slice(0, 140) + "…"
: post.content;
return (
<Swipeable
ref={swipeRef}
renderRightActions={renderRightActions}
rightThreshold={40}
friction={2}
>
<TouchableOpacity
activeOpacity={0.85}
onLongPress={() => onLongPress(post)}
delayLongPress={400}
style={[
styles.card,
{ backgroundColor: colors.card, borderBottomColor: colors.border },
]}
>
<View style={styles.header}>
<View style={styles.integrations}>
{integrations.slice(0, 3).map((intg) => (
<View
key={intg.id}
style={[
styles.networkIcon,
{ backgroundColor: colors.secondary },
]}
>
<Feather
name={getNetworkIcon(intg.type ?? intg.internalType)}
size={12}
color={colors.mutedForeground}
/>
</View>
))}
{integrations.length > 3 && (
<Text style={[styles.moreText, { color: colors.mutedForeground }]}>
+{integrations.length - 3}
</Text>
)}
</View>
<StatusBadge status={post.state} />
</View>
<Text style={[styles.content, { color: colors.foreground }]}>
{truncatedContent}
</Text>
<View style={styles.footer}>
{integrations.length > 0 && (
<>
<Text style={[styles.accountName, { color: colors.mutedForeground }]} numberOfLines={1}>
{integrations
.slice(0, 2)
.map((i) => i.name || i.identifier || "")
.filter(Boolean)
.join(", ")}
{integrations.length > 2 ? ` +${integrations.length - 2}` : ""}
</Text>
<Text style={[styles.dot, { color: colors.mutedForeground }]}>·</Text>
</>
)}
<Feather name="clock" size={12} color={colors.mutedForeground} />
<Text style={[styles.date, { color: colors.mutedForeground }]}>
{formatDate(post.publishDate)}
</Text>
</View>
</TouchableOpacity>
</Swipeable>
);
}
const styles = StyleSheet.create({
card: {
paddingHorizontal: 20,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
gap: 8,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
integrations: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
networkIcon: {
width: 22,
height: 22,
borderRadius: 11,
alignItems: "center",
justifyContent: "center",
},
moreText: {
fontSize: 11,
fontFamily: "Inter_500Medium",
},
content: {
fontSize: 14,
fontFamily: "Inter_400Regular",
lineHeight: 20,
},
footer: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
date: {
fontSize: 12,
fontFamily: "Inter_400Regular",
},
accountName: {
fontSize: 12,
fontFamily: "Inter_400Regular",
flexShrink: 1,
},
dot: {
fontSize: 12,
marginHorizontal: 3,
},
deleteAction: {
width: 72,
alignItems: "center",
justifyContent: "center",
},
});