7aacb9a53e
- 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>
258 lines
7.0 KiB
TypeScript
258 lines
7.0 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;
|
|
onReschedule?: (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, onReschedule }: 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 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
|
|
? post.content.slice(0, 140) + "…"
|
|
: post.content;
|
|
|
|
return (
|
|
<Swipeable
|
|
ref={swipeRef}
|
|
renderRightActions={renderRightActions}
|
|
renderLeftActions={renderLeftActions}
|
|
rightThreshold={40}
|
|
leftThreshold={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",
|
|
},
|
|
rescheduleAction: {
|
|
width: 72,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
});
|