d3275207bd
Commit 39d5e5d added `Bearer ${apiKey}` to the axios client but this
Postiz instance expects the raw API key with no prefix. Reverting to
the original format that was confirmed working in the initial commit.
Same fix applied to the image upload header in compose.tsx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
539 lines
15 KiB
TypeScript
539 lines
15 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 | null> => {
|
|
if (!imageUri) return null;
|
|
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);
|
|
}
|
|
const uploadRes = await expoFetch(`${baseUrl}/upload`, {
|
|
method: "POST",
|
|
headers: { Authorization: apiKey },
|
|
body: formData,
|
|
});
|
|
const data = await uploadRes.json() as PostizUploadResult;
|
|
return data;
|
|
} catch (e: unknown) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
Alert.alert("Upload Failed", `Could not upload image.\n${msg}`);
|
|
return null;
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!client) 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();
|
|
if (uploaded) {
|
|
media = [{ id: uploaded.id, path: uploaded.path }];
|
|
}
|
|
}
|
|
const payload = {
|
|
type: postNow ? "now" : "schedule",
|
|
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(),
|
|
content: [{ content: content.trim(), image: media }],
|
|
integrations: selectedChannels,
|
|
};
|
|
await client.post("posts", payload);
|
|
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);
|
|
let msg = "Could not submit post.";
|
|
if (e instanceof Error) msg += `\n${e.message}`;
|
|
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",
|
|
},
|
|
});
|