Files
Postiz-android/artifacts/postiz-mobile/app/(tabs)/compose.tsx
T
billisdead 0cf5800463
Release APK / build (push) Has been cancelled
fix(mobile): restore images on repost, improve media library 404 error, null-safe stripHtml
- Pass post.image[] as JSON prefillImages param when prefilling compose from an existing post (repost/edit/retry), replacing the broken single-image prefillImagePath/prefillImageId approach
- MediaLibraryModal now shows the attempted URL and a clear explanation on 404, making it easier to diagnose if the Postiz version does not expose GET /media
- stripHtml accepts null/undefined input and returns "" instead of throwing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 08:31:34 +02:00

861 lines
34 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 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, useMemo, 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 { LibraryMediaItem, MediaLibraryModal } from "@/components/MediaLibraryModal";
import {
PostizIntegration,
PostizUploadResult,
PostizWorkspace,
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<string, number> = {
twitter: 280, x: 280,
instagram: 2200,
linkedin: 3000,
facebook: 63206,
youtube: 5000,
tiktok: 2200,
};
// Integration enriched with its workspace info
type IntegrationWithWorkspace = PostizIntegration & {
workspaceId: string;
workspaceName: string;
workspace: PostizWorkspace;
};
type MediaItem =
| { type: "local"; uri: string }
| { type: "uploaded"; id: string; path: string; workspaceId: 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 { workspaces, clients, isConfigured } = usePostiz();
const queryClient = useQueryClient();
const { prefillContent, prefillIntegrationIds, prefillImages } =
useLocalSearchParams<{
prefillContent?: string;
prefillIntegrationIds?: string;
prefillImages?: 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 [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
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 (prefillImages && workspaces.length > 0) {
try {
const images: Array<{ id: string; path: string }> = JSON.parse(String(prefillImages));
const wsId = workspaces[0]?.id ?? "";
setMediaItems(
images
.filter((img) => img?.id && img?.path)
.map((img): MediaItem => ({
type: "uploaded",
id: img.id,
path: img.path,
workspaceId: wsId,
}))
);
} catch {}
}
}, [prefillContent, prefillIntegrationIds, prefillImages, workspaces]);
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]);
// Fetch integrations from ALL workspaces in parallel
const { data: allIntegrations, isLoading: loadingIntegrations } =
useQuery<IntegrationWithWorkspace[]>({
queryKey: ["integrations-all", workspaces.map((w) => w.id).join(",")],
queryFn: async () => {
const results = await Promise.all(
workspaces.map(async (ws) => {
const client = clients[ws.id];
if (!client) return [];
const res = await client.get("integrations");
const list: PostizIntegration[] = Array.isArray(res.data)
? res.data
: (res.data?.integrations ?? []);
return list.map((i): IntegrationWithWorkspace => ({
...i,
workspaceId: ws.id,
workspaceName: ws.name,
workspace: ws,
}));
})
);
return results.flat();
},
enabled: workspaces.length > 0 && Object.keys(clients).length > 0,
staleTime: 60000,
});
type CustomerGroup = {
customerId: string;
customerName: string;
channels: IntegrationWithWorkspace[];
};
type WorkspaceGroup = {
workspace: PostizWorkspace;
customers: CustomerGroup[];
allChannels: IntegrationWithWorkspace[];
};
// Group: workspace → customers → channels
const grouped = useMemo((): WorkspaceGroup[] => {
if (!allIntegrations) return [];
const byWorkspace = new Map<string, IntegrationWithWorkspace[]>();
for (const intg of allIntegrations) {
if (!byWorkspace.has(intg.workspaceId)) byWorkspace.set(intg.workspaceId, []);
byWorkspace.get(intg.workspaceId)!.push(intg);
}
return workspaces
.filter((ws) => byWorkspace.has(ws.id))
.map((ws) => {
const allChannels = byWorkspace.get(ws.id)!;
const byCustomer = new Map<string, CustomerGroup>();
for (const ch of allChannels) {
const cId = ch.customer?.id ?? "__default__";
const cName = ch.customer?.name ?? ws.name;
if (!byCustomer.has(cId)) byCustomer.set(cId, { customerId: cId, customerName: cName, channels: [] });
byCustomer.get(cId)!.channels.push(ch);
}
return { workspace: ws, customers: Array.from(byCustomer.values()), allChannels };
});
}, [allIntegrations, workspaces]);
const toggleWorkspace = (wsId: string) => {
const allIds = (grouped.find((g) => g.workspace.id === wsId)?.allChannels ?? []).map((c) => c.id);
const allSelected = allIds.every((id) => selectedChannels.includes(id));
if (allSelected) {
setSelectedChannels((prev) => prev.filter((id) => !allIds.includes(id)));
} else {
setSelectedChannels((prev) => [...new Set([...prev, ...allIds])]);
}
};
const toggleCustomer = (customerIds: string[]) => {
const allSelected = customerIds.every((id) => selectedChannels.includes(id));
if (allSelected) {
setSelectedChannels((prev) => prev.filter((id) => !customerIds.includes(id)));
} else {
setSelectedChannels((prev) => [...new Set([...prev, ...customerIds])]);
}
};
const effectiveCharLimit = useMemo(() => {
if (selectedChannels.length === 0 || !allIntegrations) return 3000;
const selected = allIntegrations.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);
}, [selectedChannels, allIntegrations]);
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));
};
// Upload local images to a specific workspace, returns { id, path }[]
const uploadLocalToWorkspace = async (
localUris: string[],
ws: PostizWorkspace
): Promise<Array<{ id: string; path: string }>> => {
const result: Array<{ id: string; path: string }> = [];
for (const uri of localUris) {
const formData = new FormData();
if (Platform.OS === "web") {
const response = await expoFetch(uri);
const blob = await response.blob();
formData.append("file", blob, "upload.jpg");
} else {
formData.append("file", {
uri,
name: "upload.jpg",
type: "image/jpeg",
} as unknown as Blob);
}
// eslint-disable-next-line no-undef
const uploadRes = await globalThis.fetch(`${ws.baseUrl}/upload`, {
method: "POST",
headers: { Authorization: ws.apiKey },
body: formData,
});
if (!uploadRes.ok) {
const raw = await uploadRes.text().catch(() => uploadRes.statusText);
throw new Error(`[${ws.name}] 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;
};
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 {
// Group selected channels by workspace
const byWorkspace = new Map<string, { ws: PostizWorkspace; channelIds: string[] }>();
for (const channelId of selectedChannels) {
const intg = allIntegrations?.find((i) => i.id === channelId);
if (!intg) continue;
if (!byWorkspace.has(intg.workspaceId)) {
byWorkspace.set(intg.workspaceId, { ws: intg.workspace, channelIds: [] });
}
byWorkspace.get(intg.workspaceId)!.channelIds.push(channelId);
}
const localUris = mediaItems.filter((m): m is MediaItem & { type: "local" } => m.type === "local").map((m) => m.uri);
const hasLocalImages = localUris.length > 0;
if (hasLocalImages) setUploading(true);
await Promise.all(
Array.from(byWorkspace.values()).map(async ({ ws, channelIds }) => {
// Already-uploaded media belonging to this workspace
const uploadedForWs = mediaItems
.filter((m): m is MediaItem & { type: "uploaded" } => m.type === "uploaded" && m.workspaceId === ws.id)
.map(({ id, path }) => ({ id, path }));
// Upload local images to this workspace
const localUploaded = hasLocalImages
? await uploadLocalToWorkspace(localUris, ws)
: [];
const media = [...uploadedForWs, ...localUploaded];
const payload = {
type: postNow ? "now" : "schedule",
date: postNow ? new Date().toISOString() : scheduleDate.toISOString(),
shortLink: false,
tags: [] as string[],
posts: channelIds.map((integrationId) => ({
integration: { id: integrationId },
value: [{ content: content.trim(), image: media }],
})),
};
const body = JSON.stringify(payload);
console.log("[compose] POST", `${ws.baseUrl}/posts`, body);
// eslint-disable-next-line no-undef
const res = await globalThis.fetch(`${ws.baseUrl}/posts`, {
method: "POST",
headers: { Authorization: ws.apiKey, "Content-Type": "application/json" },
body,
});
if (!res.ok) {
let detail = "";
try {
const raw = await res.text();
console.log(`[compose][${ws.name}] error body:`, raw);
detail = raw.slice(0, 500);
} catch { detail = res.statusText; }
throw new Error(`[${ws.name}] HTTP ${res.status}: ${detail}`);
}
})
);
if (hasLocalImages) setUploading(false);
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) {
setUploading(false);
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 (
<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 a workspace 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={() => setDraftBanner(false)} 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>
{mediaItems.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.imageRow}
>
{mediaItems.map((item, idx) => {
const uri =
item.type === "local"
? item.uri
: resolveMediaUrl(item.path, workspaces.find((w) => w.id === item.workspaceId)?.baseUrl ?? "");
return (
<View key={idx} style={styles.imageThumbWrap}>
<Image
source={{ uri }}
style={[styles.imageThumb, { borderColor: colors.border }]}
contentFit="cover"
/>
<TouchableOpacity
onPress={() => removeMediaItem(idx)}
style={[styles.removeImg, { backgroundColor: colors.destructive }]}
>
<Feather name="x" size={12} color="#fff" />
</TouchableOpacity>
{item.type === "uploaded" && (
<View style={[styles.uploadedBadge, { backgroundColor: colors.success }]}>
<Feather name="cloud" size={8} color="#fff" />
</View>
)}
</View>
);
})}
</ScrollView>
)}
{mediaItems.length < MAX_IMAGES && (
<View style={styles.mediaBtnsRow}>
<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 }]}>
{mediaItems.length === 0 ? "Add image" : "Add more"}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setShowMediaLibrary(true)}
activeOpacity={0.7}
style={[styles.mediaBtn, { backgroundColor: colors.card, borderColor: colors.border }]}
>
<Feather name="folder" size={16} color={colors.mutedForeground} />
<Text style={[styles.mediaBtnText, { color: colors.mutedForeground }]}>Library</Text>
</TouchableOpacity>
</View>
)}
{/* Channels grouped by workspace then network type */}
<Text style={[styles.sectionLabel, { color: colors.mutedForeground }]}>CHANNELS</Text>
{loadingIntegrations ? (
<ActivityIndicator color={colors.primary} style={{ marginVertical: 8 }} />
) : grouped.length === 0 ? (
<Text style={[styles.hint, { color: colors.mutedForeground }]}>
No channels found. Add integrations in your Postiz instance.
</Text>
) : (
<View style={styles.channelGroups}>
{grouped.map(({ workspace, customers, allChannels }, wsIdx) => {
const wsAllIds = allChannels.map((c) => c.id);
const wsSelectedCount = wsAllIds.filter((id) => selectedChannels.includes(id)).length;
const wsAllSelected = wsSelectedCount === wsAllIds.length;
const wsSomeSelected = wsSelectedCount > 0 && !wsAllSelected;
return (
<View
key={workspace.id}
style={[
styles.workspaceSection,
{ backgroundColor: colors.card, borderColor: colors.border },
wsIdx > 0 && { marginTop: 8 },
]}
>
{/* Workspace header — tap to select/deselect all */}
{workspaces.length > 1 && (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => toggleWorkspace(workspace.id)}
style={[styles.workspaceHeader, { borderBottomColor: colors.border }]}
>
<Feather name="briefcase" size={12} color={colors.primary} />
<Text style={[styles.workspaceName, { color: colors.primary, flex: 1 }]}>
{workspace.name}
</Text>
<Feather
name={wsAllSelected ? "check-square" : wsSomeSelected ? "minus-square" : "square"}
size={14}
color={wsAllSelected || wsSomeSelected ? colors.primary : colors.mutedForeground}
/>
</TouchableOpacity>
)}
{/* Customer sub-sections */}
{customers.map((cust, cIdx) => {
const custIds = cust.channels.map((c) => c.id);
const custSelectedCount = custIds.filter((id) => selectedChannels.includes(id)).length;
const custAllSelected = custSelectedCount === custIds.length;
const custSomeSelected = custSelectedCount > 0 && !custAllSelected;
return (
<View
key={cust.customerId}
style={cIdx > 0 ? [styles.customerSection, { borderTopColor: colors.border }] : undefined}
>
{/* Customer header — tap to select/deselect all channels for this customer */}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => toggleCustomer(custIds)}
style={styles.customerHeader}
>
<Text style={[styles.customerName, { color: colors.foreground }]}>
{cust.customerName}
</Text>
<Feather
name={custAllSelected ? "check-square" : custSomeSelected ? "minus-square" : "square"}
size={14}
color={custAllSelected || custSomeSelected ? colors.primary : colors.mutedForeground}
/>
</TouchableOpacity>
{/* Channel chips for this customer */}
<View style={styles.chipRow}>
{cust.channels.map((intg) => (
<ChannelChip
key={intg.id}
integration={intg}
selected={selectedChannels.includes(intg.id)}
onToggle={() => toggleChannel(intg.id)}
/>
))}
</View>
</View>
);
})}
</View>
);
})}
</View>
)}
<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>
<MediaLibraryModal
visible={showMediaLibrary}
workspaces={workspaces}
maxSelect={MAX_IMAGES - mediaItems.length}
onClose={() => setShowMediaLibrary(false)}
onSelect={(items: LibraryMediaItem[]) => {
setMediaItems((prev) =>
[
...prev,
...items.map((i): MediaItem => ({
type: "uploaded",
id: i.id,
path: i.path,
workspaceId: i.workspaceId,
})),
].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" },
channelGroups: { gap: 0 },
workspaceSection: { borderRadius: 14, borderWidth: 1, overflow: "hidden" },
workspaceHeader: {
flexDirection: "row", alignItems: "center", gap: 6,
paddingHorizontal: 12, paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
workspaceName: { fontSize: 11, fontFamily: "Inter_600SemiBold", letterSpacing: 0.5 },
customerSection: { borderTopWidth: StyleSheet.hairlineWidth },
customerHeader: {
flexDirection: "row", alignItems: "center", justifyContent: "space-between",
paddingHorizontal: 12, paddingVertical: 8,
},
customerName: { fontSize: 13, fontFamily: "Inter_600SemiBold" },
chipRow: { flexDirection: "row", flexWrap: "wrap", gap: 6, paddingHorizontal: 10, paddingBottom: 10 },
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" },
});