69b94ab7c0
expoFetch does not support the React Native FormData { uri, name, type }
pattern. Switch upload request to globalThis.fetch which handles it
correctly. Also propagate upload errors instead of swallowing them.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
561 lines
16 KiB
TypeScript
561 lines
16 KiB
TypeScript
import { Feather } from "@expo/vector-icons";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import DateTimePicker from "@react-native-community/datetimepicker";
|
|
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 React, { 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";
|
|
|
|
export default function ComposeScreen() {
|
|
const colors = useColors();
|
|
const insets = useSafeAreaInsets();
|
|
const { client, isConfigured, apiKey, baseUrl } = usePostiz();
|
|
const queryClient = useQueryClient();
|
|
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 [uploading, setUploading] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
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 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);
|
|
}
|
|
};
|
|
|
|
const removeImage = () => setImageUri(null);
|
|
|
|
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 }> = [];
|
|
if (imageUri) {
|
|
const uploaded = await uploadImage();
|
|
media = [{ id: uploaded.id, path: uploaded.path }];
|
|
}
|
|
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(), id: "", image: media }],
|
|
})),
|
|
};
|
|
const body = JSON.stringify(payload);
|
|
|
|
// 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();
|
|
detail = raw.slice(0, 300);
|
|
} catch {
|
|
detail = res.statusText;
|
|
}
|
|
throw new Error(`HTTP ${res.status}: ${detail}`);
|
|
}
|
|
|
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
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);
|
|
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}
|
|
>
|
|
<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={3000}
|
|
textAlignVertical="top"
|
|
/>
|
|
<Text style={[styles.charCount, { color: colors.mutedForeground }]}>
|
|
{content.length}/3000
|
|
</Text>
|
|
</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={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,
|
|
},
|
|
charCount: {
|
|
fontSize: 11,
|
|
fontFamily: "Inter_400Regular",
|
|
alignSelf: "flex-end",
|
|
marginTop: 4,
|
|
},
|
|
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",
|
|
},
|
|
});
|