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,6 +1,7 @@
|
||||
import { Feather } from "@expo/vector-icons";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import DateTimePicker from "@react-native-community/datetimepicker";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Image } from "expo-image";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
@@ -24,16 +25,29 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ChannelChip } from "@/components/ChannelChip";
|
||||
import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext";
|
||||
import { useColors } from "@/hooks/useColors";
|
||||
const DRAFT_STORAGE_KEY = "postiz_local_draft";
|
||||
|
||||
const NETWORK_CHAR_LIMITS: Record<string, number> = {
|
||||
twitter: 280, x: 280,
|
||||
instagram: 2200,
|
||||
linkedin: 3000,
|
||||
facebook: 63206,
|
||||
youtube: 5000,
|
||||
tiktok: 2200,
|
||||
};
|
||||
|
||||
export default function ComposeScreen() {
|
||||
const colors = useColors();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { client, isConfigured, apiKey, baseUrl } = usePostiz();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefillContent, prefillIntegrationIds } = useLocalSearchParams<{
|
||||
prefillContent?: string;
|
||||
prefillIntegrationIds?: string;
|
||||
}>();
|
||||
const { prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId } =
|
||||
useLocalSearchParams<{
|
||||
prefillContent?: string;
|
||||
prefillIntegrationIds?: string;
|
||||
prefillImagePath?: string;
|
||||
prefillImageId?: string;
|
||||
}>();
|
||||
const [content, setContent] = useState("");
|
||||
const [selectedChannels, setSelectedChannels] = useState<string[]>([]);
|
||||
const [postNow, setPostNow] = useState(false);
|
||||
@@ -43,15 +57,34 @@ export default function ComposeScreen() {
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [showTimePicker, setShowTimePicker] = useState(false);
|
||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||
const [existingMedia, setExistingMedia] = useState<Array<{ id: string; path: string }>>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [draftBanner, setDraftBanner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (prefillContent) setContent(String(prefillContent));
|
||||
if (prefillContent) {
|
||||
setContent(String(prefillContent));
|
||||
}
|
||||
if (prefillIntegrationIds) {
|
||||
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
|
||||
}
|
||||
}, [prefillContent, prefillIntegrationIds]);
|
||||
if (prefillImagePath && prefillImageId) {
|
||||
setExistingMedia([{ id: String(prefillImageId), path: String(prefillImagePath) }]);
|
||||
setImageUri(String(prefillImagePath));
|
||||
}
|
||||
}, [prefillContent, prefillIntegrationIds, prefillImagePath, prefillImageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prefillContent) return;
|
||||
AsyncStorage.getItem(DRAFT_STORAGE_KEY).then((raw) => {
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft = JSON.parse(raw);
|
||||
if (draft?.content) setDraftBanner(true);
|
||||
} catch {}
|
||||
});
|
||||
}, [prefillContent]);
|
||||
|
||||
const { data: integrations, isLoading: loadingIntegrations } =
|
||||
useQuery<PostizIntegration[]>({
|
||||
@@ -65,6 +98,39 @@ export default function ComposeScreen() {
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
const effectiveCharLimit = (() => {
|
||||
if (selectedChannels.length === 0 || !integrations) return 3000;
|
||||
const selected = integrations.filter((i) => selectedChannels.includes(i.id));
|
||||
const limits = selected.map((i) => {
|
||||
const t = (i.type ?? i.internalType ?? "").toLowerCase();
|
||||
for (const [key, limit] of Object.entries(NETWORK_CHAR_LIMITS)) {
|
||||
if (t.includes(key)) return limit;
|
||||
}
|
||||
return 3000;
|
||||
});
|
||||
return Math.min(...limits);
|
||||
})();
|
||||
|
||||
const saveDraft = async () => {
|
||||
const draft = { content, integrationIds: selectedChannels };
|
||||
await AsyncStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
Alert.alert("Draft saved", "Your draft has been saved locally.");
|
||||
};
|
||||
|
||||
const restoreDraft = async () => {
|
||||
const raw = await AsyncStorage.getItem(DRAFT_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const draft = JSON.parse(raw);
|
||||
if (draft.content) setContent(draft.content);
|
||||
if (draft.integrationIds?.length) setSelectedChannels(draft.integrationIds);
|
||||
setDraftBanner(false);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const dismissDraft = () => setDraftBanner(false);
|
||||
|
||||
const toggleChannel = (id: string) => {
|
||||
setSelectedChannels((prev) =>
|
||||
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id]
|
||||
@@ -84,10 +150,14 @@ export default function ComposeScreen() {
|
||||
});
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImageUri(result.assets[0].uri);
|
||||
setExistingMedia([]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = () => setImageUri(null);
|
||||
const removeImage = () => {
|
||||
setImageUri(null);
|
||||
setExistingMedia([]);
|
||||
};
|
||||
|
||||
const uploadImage = async (): Promise<PostizUploadResult> => {
|
||||
setUploading(true);
|
||||
@@ -134,9 +204,12 @@ export default function ComposeScreen() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let media: Array<{ id: string; path: string }> = [];
|
||||
if (imageUri) {
|
||||
const isLocalFile = imageUri && !imageUri.startsWith("http");
|
||||
if (imageUri && isLocalFile) {
|
||||
const uploaded = await uploadImage();
|
||||
media = [{ id: uploaded.id, path: uploaded.path }];
|
||||
} else if (existingMedia.length > 0) {
|
||||
media = existingMedia;
|
||||
}
|
||||
const payload = {
|
||||
type: postNow ? "now" : "schedule",
|
||||
@@ -176,6 +249,7 @@ export default function ComposeScreen() {
|
||||
}
|
||||
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||
Alert.alert(
|
||||
"Posted!",
|
||||
postNow ? "Your post has been published." : "Post scheduled successfully.",
|
||||
@@ -197,6 +271,8 @@ export default function ComposeScreen() {
|
||||
setSelectedChannels([]);
|
||||
setPostNow(false);
|
||||
setImageUri(null);
|
||||
setExistingMedia([]);
|
||||
setDraftBanner(false);
|
||||
setScheduleDate(new Date(Date.now() + 60 * 60 * 1000));
|
||||
};
|
||||
|
||||
@@ -238,6 +314,21 @@ export default function ComposeScreen() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{draftBanner && (
|
||||
<View style={[styles.draftBanner, { backgroundColor: colors.card, borderColor: colors.border }]}>
|
||||
<Feather name="file-text" size={14} color={colors.primary} />
|
||||
<Text style={[styles.draftBannerText, { color: colors.foreground }]}>
|
||||
You have a saved draft
|
||||
</Text>
|
||||
<TouchableOpacity onPress={restoreDraft} activeOpacity={0.7}>
|
||||
<Text style={[styles.draftBannerAction, { color: colors.primary }]}>Restore</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={dismissDraft} activeOpacity={0.7}>
|
||||
<Feather name="x" size={14} color={colors.mutedForeground} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={[
|
||||
styles.textArea,
|
||||
@@ -251,12 +342,31 @@ export default function ComposeScreen() {
|
||||
multiline
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
maxLength={3000}
|
||||
maxLength={effectiveCharLimit}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<Text style={[styles.charCount, { color: colors.mutedForeground }]}>
|
||||
{content.length}/3000
|
||||
</Text>
|
||||
<View style={styles.charCountRow}>
|
||||
{effectiveCharLimit < 3000 && selectedChannels.length > 0 && (
|
||||
<Text style={[styles.charCountLabel, { color: colors.mutedForeground }]}>
|
||||
limit: {effectiveCharLimit}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.charCount,
|
||||
{
|
||||
color:
|
||||
content.length >= effectiveCharLimit
|
||||
? colors.error
|
||||
: content.length > effectiveCharLimit * 0.9
|
||||
? colors.warning
|
||||
: colors.mutedForeground,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{content.length}/{effectiveCharLimit}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{imageUri && (
|
||||
@@ -410,6 +520,16 @@ export default function ComposeScreen() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={saveDraft}
|
||||
activeOpacity={0.7}
|
||||
disabled={submitting || uploading || !content.trim()}
|
||||
style={[styles.draftBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
|
||||
>
|
||||
<Feather name="file-text" size={14} color={colors.mutedForeground} />
|
||||
<Text style={[styles.draftBtnText, { color: colors.mutedForeground }]}>Save Draft</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleSubmit}
|
||||
activeOpacity={0.85}
|
||||
@@ -464,11 +584,51 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 22,
|
||||
minHeight: 100,
|
||||
},
|
||||
charCountRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: 4,
|
||||
},
|
||||
charCountLabel: {
|
||||
fontSize: 10,
|
||||
fontFamily: "Inter_400Regular",
|
||||
},
|
||||
charCount: {
|
||||
fontSize: 11,
|
||||
fontFamily: "Inter_400Regular",
|
||||
alignSelf: "flex-end",
|
||||
marginTop: 4,
|
||||
},
|
||||
draftBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
},
|
||||
draftBannerText: {
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
fontFamily: "Inter_400Regular",
|
||||
},
|
||||
draftBannerAction: {
|
||||
fontSize: 13,
|
||||
fontFamily: "Inter_600SemiBold",
|
||||
},
|
||||
draftBtn: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
},
|
||||
draftBtnText: {
|
||||
fontSize: 13,
|
||||
fontFamily: "Inter_500Medium",
|
||||
},
|
||||
imagePreviewWrap: {
|
||||
position: "relative",
|
||||
|
||||
Reference in New Issue
Block a user