Files
Postiz-android/artifacts/postiz-mobile/app/(tabs)/compose.tsx
T
billisdead 7aacb9a53e 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>
2026-06-07 22:20:56 +02:00

737 lines
22 KiB
TypeScript

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";
import { fetch as expoFetch } from "expo/fetch";
import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Platform,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-controller";
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, 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);
const [scheduleDate, setScheduleDate] = useState(
() => new Date(Date.now() + 60 * 60 * 1000)
);
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 (prefillIntegrationIds) {
setSelectedChannels(String(prefillIntegrationIds).split(",").filter(Boolean));
}
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[]>({
queryKey: ["integrations", !!client],
queryFn: async () => {
if (!client) return [];
const res = await client.get("integrations");
return Array.isArray(res.data) ? res.data : res.data?.integrations ?? [];
},
enabled: !!client,
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]
);
};
const pickImage = async () => {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== "granted") {
Alert.alert("Permission required", "Allow access to your photo library.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: false,
quality: 0.85,
});
if (!result.canceled && result.assets[0]) {
setImageUri(result.assets[0].uri);
setExistingMedia([]);
}
};
const removeImage = () => {
setImageUri(null);
setExistingMedia([]);
};
const uploadImage = async (): Promise<PostizUploadResult> => {
setUploading(true);
try {
const formData = new FormData();
if (Platform.OS === "web") {
const response = await expoFetch(imageUri!);
const blob = await response.blob();
formData.append("file", blob, "upload.jpg");
} else {
formData.append("file", {
uri: imageUri!,
name: "upload.jpg",
type: "image/jpeg",
} as unknown as Blob);
}
// eslint-disable-next-line no-undef
const uploadRes = await globalThis.fetch(`${baseUrl}/upload`, {
method: "POST",
headers: { Authorization: apiKey },
body: formData,
});
if (!uploadRes.ok) {
const raw = await uploadRes.text().catch(() => uploadRes.statusText);
throw new Error(`Upload HTTP ${uploadRes.status}: ${raw.slice(0, 200)}`);
}
return await uploadRes.json() as PostizUploadResult;
} finally {
setUploading(false);
}
};
const handleSubmit = async () => {
if (!isConfigured) return;
if (!content.trim()) {
Alert.alert("Empty post", "Please write something before posting.");
return;
}
if (selectedChannels.length === 0) {
Alert.alert("No channel", "Please select at least one channel.");
return;
}
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setSubmitting(true);
try {
let media: Array<{ id: string; path: string }> = [];
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",
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(),
shortLink: false,
tags: [] as string[],
posts: selectedChannels.map((integrationId) => {
return {
integration: { id: integrationId },
value: [{ content: content.trim(), image: media }],
};
}),
};
const body = JSON.stringify(payload);
console.log("[compose] POST", `${baseUrl}/posts`, body);
// eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${baseUrl}/posts`, {
method: "POST",
headers: {
Authorization: apiKey,
"Content-Type": "application/json",
},
body,
});
if (!res.ok) {
let detail = "";
try {
const raw = await res.text();
console.log("[compose] 400 body:", raw);
detail = raw.slice(0, 500);
} catch {
detail = res.statusText;
}
throw new Error(`HTTP ${res.status}: ${detail}`);
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await AsyncStorage.removeItem(DRAFT_STORAGE_KEY);
Alert.alert(
"Posted!",
postNow ? "Your post has been published." : "Post scheduled successfully.",
[{ text: "OK", onPress: resetForm }]
);
queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["posts-list"] });
} catch (e: unknown) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
const msg = e instanceof Error ? e.message : "Could not submit post.";
Alert.alert("Failed", msg);
} finally {
setSubmitting(false);
}
};
const resetForm = () => {
setContent("");
setSelectedChannels([]);
setPostNow(false);
setImageUri(null);
setExistingMedia([]);
setDraftBanner(false);
setScheduleDate(new Date(Date.now() + 60 * 60 * 1000));
};
const formatDateLabel = (d: Date) =>
d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const formatTimeLabel = (d: Date) =>
d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
if (!isConfigured) {
return (
<View style={[styles.centered, { backgroundColor: colors.background }]}>
<Feather name="lock" size={32} color={colors.mutedForeground} />
<Text style={[styles.sectionTitle, { color: colors.foreground }]}>
Not Configured
</Text>
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
Add your API key in Settings
</Text>
</View>
);
}
return (
<KeyboardAwareScrollView
style={{ flex: 1, backgroundColor: colors.background }}
contentContainerStyle={[
styles.container,
{
paddingTop: Platform.OS === "web" ? 67 : 16,
paddingBottom: Platform.OS === "web" ? 100 : insets.bottom + 80,
},
]}
bottomOffset={80}
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,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<TextInput
style={[styles.textInput, { color: colors.foreground }]}
placeholder="What do you want to post?"
placeholderTextColor={colors.mutedForeground}
multiline
value={content}
onChangeText={setContent}
maxLength={effectiveCharLimit}
textAlignVertical="top"
/>
<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 && (
<View style={styles.imagePreviewWrap}>
<Image
source={{ uri: imageUri }}
style={[styles.imagePreview, { borderColor: colors.border }]}
contentFit="cover"
/>
<TouchableOpacity
onPress={removeImage}
style={[styles.removeImg, { backgroundColor: colors.destructive }]}
>
<Feather name="x" size={12} color="#fff" />
</TouchableOpacity>
</View>
)}
<TouchableOpacity
onPress={pickImage}
activeOpacity={0.7}
style={[
styles.mediaBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<Feather name="image" size={16} color={colors.mutedForeground} />
<Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}>
{imageUri ? "Change image" : "Add image"}
</Text>
</TouchableOpacity>
<Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>
CHANNELS
</Text>
{loadingIntegrations ? (
<ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} />
) : (integrations ?? []).length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
No channels found. Add integrations in your Postiz instance.
</Text>
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.channelList}
>
{(integrations ?? []).map((intg) => (
<ChannelChip
key={intg.id}
integration={intg}
selected={selectedChannels.includes(intg.id)}
onToggle={() => toggleChannel(intg.id)}
/>
))}
</ScrollView>
)}
<View
style={[
styles.scheduleRow,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
>
<View style={styles.scheduleRowLeft}>
<Feather name="zap" size={16} color={colors.primary} />
<Text style={[styles.scheduleLabel, { color: colors.foreground }]}>
Post now
</Text>
</View>
<Switch
value={postNow}
onValueChange={setPostNow}
trackColor={{ false: colors.muted, true: colors.primary }}
thumbColor={colors.primaryForeground}
/>
</View>
{!postNow && (
<View style={styles.dateTimeRow}>
<TouchableOpacity
onPress={() => {
setShowTimePicker(false);
setShowDatePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
activeOpacity={0.7}
>
<Feather name="calendar" size={14} color={colors.primary} />
<Text style={[styles.dateBtnText, { color: colors.foreground }]}>
{formatDateLabel(scheduleDate)}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setShowDatePicker(false);
setShowTimePicker((v) => !v);
}}
style={[
styles.dateBtn,
{ backgroundColor: colors.card, borderColor: colors.border },
]}
activeOpacity={0.7}
>
<Feather name="clock" size={14} color={colors.primary} />
<Text style={[styles.dateBtnText, { color: colors.foreground }]}>
{formatTimeLabel(scheduleDate)}
</Text>
</TouchableOpacity>
</View>
)}
{showDatePicker && (
<DateTimePicker
value={scheduleDate}
mode="date"
display={Platform.OS === "ios" ? "spinner" : "default"}
minimumDate={new Date()}
textColor={colors.foreground}
accentColor={colors.primary}
onChange={(_: unknown, date?: Date) => {
if (Platform.OS !== "ios") setShowDatePicker(false);
if (date) {
const merged = new Date(scheduleDate);
merged.setFullYear(date.getFullYear(), date.getMonth(), date.getDate());
setScheduleDate(merged);
}
}}
/>
)}
{showTimePicker && (
<DateTimePicker
value={scheduleDate}
mode="time"
display={Platform.OS === "ios" ? "spinner" : "default"}
textColor={colors.foreground}
accentColor={colors.primary}
onChange={(_: unknown, date?: Date) => {
if (Platform.OS !== "ios") setShowTimePicker(false);
if (date) {
const merged = new Date(scheduleDate);
merged.setHours(date.getHours(), date.getMinutes());
setScheduleDate(merged);
}
}}
/>
)}
<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}
disabled={submitting || uploading}
style={[
styles.submitBtn,
{
backgroundColor:
submitting || uploading ? colors.muted : colors.primary,
},
]}
>
{submitting || uploading ? (
<ActivityIndicator color={colors.primaryForeground} size="small" />
) : (
<>
<Feather
name={postNow ? "send" : "clock"}
size={16}
color={colors.primaryForeground}
/>
<Text style={[styles.submitText, { color: colors.primaryForeground }]}>
{postNow ? "Publish Now" : "Schedule Post"}
</Text>
</>
)}
</TouchableOpacity>
</KeyboardAwareScrollView>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 16,
gap: 14,
},
centered: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 10,
},
textArea: {
borderRadius: 14,
borderWidth: 1,
padding: 14,
minHeight: 140,
},
textInput: {
fontSize: 15,
fontFamily: "Inter_400Regular",
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",
},
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",
alignSelf: "flex-start",
},
imagePreview: {
width: 120,
height: 120,
borderRadius: 10,
borderWidth: 1,
},
removeImg: {
position: "absolute",
top: 4,
right: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
},
mediaBtn: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
alignSelf: "flex-start",
},
mediaBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
},
sectionLabel: {
fontSize: 11,
fontFamily: "Inter_600SemiBold",
letterSpacing: 0.8,
marginBottom: -6,
},
sectionTitle: {
fontSize: 18,
fontFamily: "Inter_600SemiBold",
},
hint: {
fontSize: 13,
fontFamily: "Inter_400Regular",
textAlign: "center",
},
channelList: {
flexDirection: "row",
gap: 8,
flexWrap: "wrap",
},
scheduleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
},
scheduleRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: 10,
},
scheduleLabel: {
fontSize: 15,
fontFamily: "Inter_500Medium",
},
dateTimeRow: {
flexDirection: "row",
gap: 10,
},
dateBtn: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
},
dateBtnText: {
fontSize: 13,
fontFamily: "Inter_500Medium",
},
submitBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
paddingVertical: 14,
borderRadius: 14,
marginTop: 4,
},
submitText: {
fontSize: 15,
fontFamily: "Inter_600SemiBold",
},
});