feat: UX improvements, security hardening, and code cleanup
- 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>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
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 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, { useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
@@ -21,24 +21,9 @@ 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";
|
||||
|
||||
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";
|
||||
}
|
||||
const SORT_STORAGE_KEY = "postiz_posts_sort";
|
||||
|
||||
type FilterType = "all" | "QUEUE" | "PUBLISHED" | "ERROR" | "DRAFT";
|
||||
|
||||
@@ -59,6 +44,19 @@ export default function PostsScreen() {
|
||||
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);
|
||||
@@ -98,6 +96,17 @@ export default function PostsScreen() {
|
||||
});
|
||||
}, [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();
|
||||
@@ -190,7 +199,12 @@ export default function PostsScreen() {
|
||||
|
||||
buttons.push({
|
||||
text: "Copy text",
|
||||
onPress: () => Clipboard.setStringAsync(post.content),
|
||||
onPress: async () => {
|
||||
await Clipboard.setStringAsync(post.content);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setCopyToast(true);
|
||||
setTimeout(() => setCopyToast(false), 2000);
|
||||
},
|
||||
});
|
||||
|
||||
if (post.state === "ERROR") {
|
||||
@@ -258,39 +272,37 @@ export default function PostsScreen() {
|
||||
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
|
||||
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.filterText,
|
||||
styles.filterChip,
|
||||
{
|
||||
color:
|
||||
filter === item.key
|
||||
? colors.primaryForeground
|
||||
: colors.mutedForeground,
|
||||
backgroundColor: active ? colors.primary : colors.secondary,
|
||||
borderColor: active ? colors.primary : colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.filterText,
|
||||
{ color: active ? colors.primaryForeground : colors.mutedForeground },
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
{count !== undefined && count > 0 ? ` ${count}` : ""}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}}
|
||||
style={styles.filterBar}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setSortOrder((o) => (o === "desc" ? "asc" : "desc"))}
|
||||
onPress={toggleSort}
|
||||
activeOpacity={0.7}
|
||||
style={[styles.sortBtn, { borderColor: colors.border, backgroundColor: colors.secondary }]}
|
||||
>
|
||||
@@ -302,6 +314,13 @@ export default function PostsScreen() {
|
||||
</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" />
|
||||
@@ -333,6 +352,7 @@ export default function PostsScreen() {
|
||||
post={item}
|
||||
onDelete={handleDelete}
|
||||
onLongPress={showContextMenu}
|
||||
onReschedule={startReschedule}
|
||||
/>
|
||||
)}
|
||||
refreshControl={
|
||||
@@ -415,4 +435,17 @@ const styles = StyleSheet.create({
|
||||
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" },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user