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 ImageManipulator from "expo-image-manipulator"; 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 { MediaLibraryModal } from "@/components/MediaLibraryModal"; import { PostizIntegration, PostizUploadResult, usePostiz } from "@/context/PostizContext"; import { useColors } from "@/hooks/useColors"; const DRAFT_STORAGE_KEY = "postiz_local_draft"; const MAX_IMAGES = 4; const NETWORK_CHAR_LIMITS: Record = { twitter: 280, x: 280, instagram: 2200, linkedin: 3000, facebook: 63206, youtube: 5000, tiktok: 2200, }; type MediaItem = | { type: "local"; uri: string } | { type: "uploaded"; id: string; path: string }; function resolveMediaUrl(path: string, baseUrl: string): string { if (path.startsWith("http://") || path.startsWith("https://")) return path; const origin = baseUrl.replace(/\/api\/.*$/, ""); return `${origin}/${path.replace(/^\//, "")}`; } 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([]); 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 [mediaItems, setMediaItems] = useState([]); const [showMediaLibrary, setShowMediaLibrary] = useState(false); 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) { setMediaItems([{ type: "uploaded", id: String(prefillImageId), path: 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({ 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 toggleChannel = (id: string) => { setSelectedChannels((prev) => prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id] ); }; const pickImage = async () => { if (mediaItems.length >= MAX_IMAGES) { Alert.alert("Max images", `You can add up to ${MAX_IMAGES} images per post.`); return; } const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (status !== "granted") { Alert.alert("Permission required", "Allow access to your photo library."); return; } const remaining = MAX_IMAGES - mediaItems.length; const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], allowsMultipleSelection: true, selectionLimit: remaining, allowsEditing: false, quality: 1, }); if (!result.canceled && result.assets.length > 0) { const MAX_DIM = 1920; const processed: string[] = []; for (const asset of result.assets) { const w = asset.width ?? 0; const h = asset.height ?? 0; if (w > MAX_DIM || h > MAX_DIM) { const landscape = w >= h; const resized = await ImageManipulator.manipulateAsync( asset.uri, [{ resize: landscape ? { width: MAX_DIM } : { height: MAX_DIM } }], { compress: 0.85, format: ImageManipulator.SaveFormat.JPEG } ); processed.push(resized.uri); } else { processed.push(asset.uri); } } setMediaItems((prev) => [...prev, ...processed.map((uri): MediaItem => ({ type: "local", uri }))].slice(0, MAX_IMAGES) ); } }; const removeMediaItem = (index: number) => { setMediaItems((prev) => prev.filter((_, i) => i !== index)); }; const buildMediaPayload = async (): Promise> => { setUploading(true); try { const result: Array<{ id: string; path: string }> = []; for (const item of mediaItems) { if (item.type === "uploaded") { result.push({ id: item.id, path: item.path }); continue; } const formData = new FormData(); if (Platform.OS === "web") { const response = await expoFetch(item.uri); const blob = await response.blob(); formData.append("file", blob, "upload.jpg"); } else { formData.append("file", { uri: item.uri, 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)}`); } const uploaded = (await uploadRes.json()) as PostizUploadResult; result.push({ id: uploaded.id, path: uploaded.path }); } return result; } 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 { const media = mediaItems.length > 0 ? await buildMediaPayload() : []; const payload = { type: postNow ? "now" : "schedule", date: postNow ? new Date().toISOString() : scheduleDate.toISOString(), shortLink: false, tags: [] as string[], posts: selectedChannels.map((integrationId) => ({ 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] error 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); setMediaItems([]); 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 ( Not Configured Add your API key in Settings ); } return ( <> {draftBanner && ( You have a saved draft Restore setDraftBanner(false)} activeOpacity={0.7}> )} {effectiveCharLimit < 3000 && selectedChannels.length > 0 && ( limit: {effectiveCharLimit} )} = effectiveCharLimit ? colors.error : content.length > effectiveCharLimit * 0.9 ? colors.warning : colors.mutedForeground, }, ]} > {content.length}/{effectiveCharLimit} {mediaItems.length > 0 && ( {mediaItems.map((item, idx) => { const uri = item.type === "local" ? item.uri : resolveMediaUrl(item.path, baseUrl); return ( removeMediaItem(idx)} style={[styles.removeImg, { backgroundColor: colors.destructive }]} > {item.type === "uploaded" && ( )} ); })} )} {mediaItems.length < MAX_IMAGES && ( {mediaItems.length === 0 ? "Add image" : "Add more"} setShowMediaLibrary(true)} activeOpacity={0.7} style={[styles.mediaBtn, { backgroundColor: colors.card, borderColor: colors.border }]} > Library )} CHANNELS {loadingIntegrations ? ( ) : (integrations ?? []).length === 0 ? ( No channels found. Add integrations in your Postiz instance. ) : ( {(integrations ?? []).map((intg) => ( toggleChannel(intg.id)} /> ))} )} Post now {!postNow && ( { setShowTimePicker(false); setShowDatePicker((v) => !v); }} style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]} activeOpacity={0.7} > {formatDateLabel(scheduleDate)} { setShowDatePicker(false); setShowTimePicker((v) => !v); }} style={[styles.dateBtn, { backgroundColor: colors.card, borderColor: colors.border }]} activeOpacity={0.7} > {formatTimeLabel(scheduleDate)} )} {showDatePicker && ( { if (Platform.OS !== "ios") setShowDatePicker(false); if (date) { const merged = new Date(scheduleDate); merged.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); setScheduleDate(merged); } }} /> )} {showTimePicker && ( { if (Platform.OS !== "ios") setShowTimePicker(false); if (date) { const merged = new Date(scheduleDate); merged.setHours(date.getHours(), date.getMinutes()); setScheduleDate(merged); } }} /> )} Save Draft {submitting || uploading ? ( ) : ( <> {postNow ? "Publish Now" : "Schedule Post"} )} setShowMediaLibrary(false)} onSelect={(items) => { setMediaItems((prev) => [...prev, ...items.map((i): MediaItem => ({ type: "uploaded", id: i.id, path: i.path }))].slice(0, MAX_IMAGES) ); setShowMediaLibrary(false); }} /> ); } 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" }, imageRow: { gap: 10, paddingRight: 4 }, imageThumbWrap: { position: "relative" }, imageThumb: { width: 100, height: 100, borderRadius: 10, borderWidth: 1 }, removeImg: { position: "absolute", top: 4, right: 4, width: 20, height: 20, borderRadius: 10, alignItems: "center", justifyContent: "center" }, uploadedBadge: { position: "absolute", bottom: 4, left: 4, width: 16, height: 16, borderRadius: 8, alignItems: "center", justifyContent: "center" }, mediaBtnsRow: { flexDirection: "row", gap: 8 }, mediaBtn: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 14, paddingVertical: 10, borderRadius: 10, borderWidth: 1 }, 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" }, });